├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── banner_dark.png ├── banner_light.png ├── egress-architecture.png ├── egress-instance.png └── workflows │ ├── publish-chrome.yaml │ ├── publish-egress.yaml │ ├── publish-gstreamer-base.yaml │ ├── publish-gstreamer.yaml │ ├── publish-template-sdk.yaml │ ├── publish-template.yaml │ ├── test-integration.yaml │ └── test-template.yaml ├── .gitignore ├── LICENSE ├── NOTICE ├── README.md ├── bootstrap.sh ├── build ├── chrome │ ├── Dockerfile │ ├── README.md │ ├── install-chrome │ └── scripts │ │ ├── amd64.sh │ │ ├── arm64.sh │ │ ├── driver.sh │ │ └── setup.sh ├── egress │ ├── Dockerfile │ └── entrypoint.sh ├── gstreamer │ ├── Dockerfile-base │ ├── Dockerfile-dev │ ├── Dockerfile-prod │ ├── Dockerfile-prod-rs │ ├── compile │ ├── compile-rs │ ├── install-dependencies │ └── tag.sh ├── template │ └── Dockerfile └── test │ ├── Dockerfile │ └── entrypoint.sh ├── chrome-sandboxing-seccomp-profile.json ├── cmd └── server │ ├── http.go │ └── main.go ├── go.mod ├── go.sum ├── magefile.go ├── pkg ├── config │ ├── base.go │ ├── config_test.go │ ├── encoding.go │ ├── manifest.go │ ├── output.go │ ├── output_file.go │ ├── output_image.go │ ├── output_segment.go │ ├── output_stream.go │ ├── pipeline.go │ ├── service.go │ ├── storage.go │ ├── urls.go │ └── urls_test.go ├── errors │ └── errors.go ├── gstreamer │ ├── bin.go │ ├── builder.go │ ├── callbacks.go │ ├── pads.go │ ├── pipeline.go │ └── state.go ├── handler │ ├── handler.go │ ├── handler_ipc.go │ └── handler_rpc.go ├── info │ └── io.go ├── ipc │ ├── conn.go │ ├── ipc.pb.go │ ├── ipc.proto │ └── ipc_grpc.pb.go ├── logging │ ├── csv.go │ ├── handler.go │ └── s3.go ├── pipeline │ ├── builder │ │ ├── audio.go │ │ ├── file.go │ │ ├── image.go │ │ ├── segment.go │ │ ├── stream.go │ │ ├── video.go │ │ └── websocket.go │ ├── controller.go │ ├── debug.go │ ├── sink │ │ ├── file.go │ │ ├── image.go │ │ ├── m3u8 │ │ │ ├── writer.go │ │ │ └── writer_test.go │ │ ├── segments.go │ │ ├── sink.go │ │ ├── stream.go │ │ ├── uploader │ │ │ ├── alioss.go │ │ │ ├── azure.go │ │ │ ├── gcp.go │ │ │ ├── local.go │ │ │ ├── s3.go │ │ │ ├── uploader.go │ │ │ └── uploader_test.go │ │ └── websocket.go │ ├── source │ │ ├── pulse │ │ │ └── pactl.go │ │ ├── sdk.go │ │ ├── sdk │ │ │ ├── appwriter.go │ │ │ └── translator.go │ │ ├── source.go │ │ └── web.go │ └── watch.go ├── server │ ├── server.go │ ├── server_ipc.go │ └── server_rpc.go ├── service │ ├── debug.go │ ├── metrics.go │ └── process.go ├── stats │ ├── handler.go │ ├── monitor.go │ └── monitor_prom.go └── types │ ├── types.go │ └── types_test.go ├── renovate.json ├── template-default ├── .gitignore ├── .prettierrc ├── README.md ├── package.json ├── pnpm-lock.yaml ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo.png │ ├── logo.svg │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.css │ ├── App.tsx │ ├── Room.tsx │ ├── SingleSpeakerLayout.tsx │ ├── SpeakerLayout.tsx │ ├── common.ts │ ├── index.css │ ├── index.tsx │ └── react-app-env.d.ts ├── tsconfig.eslint.json └── tsconfig.json ├── template-sdk ├── .gitignore ├── .npmignore ├── .prettierrc ├── README.md ├── package.json ├── pnpm-lock.yaml ├── src │ └── index.ts └── tsconfig.json ├── test ├── builder.go ├── config-sample.yaml ├── download.go ├── edge.go ├── ffprobe.go ├── file.go ├── flags.go ├── images.go ├── integration.go ├── integration_test.go ├── ioserver.go ├── multi.go ├── publish.go ├── runner.go ├── segments.go └── stream.go └── version └── version.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @livekit/media-devs 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report an egress issue 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: frostbyte73 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **Egress Version** 14 | What version are you running? 15 | 16 | **Egress Request** 17 | Post the request here (be sure to remove any PII). 18 | 19 | **Additional context** 20 | Add any other context about the problem here. 21 | 22 | **Logs** 23 | Post any relevant logs from the egress service here. 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "[FEATURE]" 5 | labels: enhancement, help wanted 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/banner_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit/egress/5f3ebf8e84f78c96ed4bdf90b0e15136887c0ab5/.github/banner_dark.png -------------------------------------------------------------------------------- /.github/banner_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit/egress/5f3ebf8e84f78c96ed4bdf90b0e15136887c0ab5/.github/banner_light.png -------------------------------------------------------------------------------- /.github/egress-architecture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit/egress/5f3ebf8e84f78c96ed4bdf90b0e15136887c0ab5/.github/egress-architecture.png -------------------------------------------------------------------------------- /.github/egress-instance.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit/egress/5f3ebf8e84f78c96ed4bdf90b0e15136887c0ab5/.github/egress-instance.png -------------------------------------------------------------------------------- /.github/workflows/publish-egress.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Publish Egress 16 | 17 | # Controls when the action will run. 18 | on: 19 | workflow_dispatch: 20 | push: 21 | # only publish on version tags 22 | tags: 23 | - 'v*.*.*' 24 | jobs: 25 | docker: 26 | runs-on: buildjet-8vcpu-ubuntu-2204 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - uses: actions/cache@v4 31 | with: 32 | path: | 33 | ~/go/pkg/mod 34 | ~/go/bin 35 | ~/.cache 36 | key: "${{ runner.os }}-egress-${{ hashFiles('**/go.sum') }}" 37 | restore-keys: ${{ runner.os }}-egress 38 | 39 | - name: Docker metadata 40 | id: docker-md 41 | uses: docker/metadata-action@v5 42 | with: 43 | images: livekit/egress 44 | tags: | 45 | type=semver,pattern=v{{version}} 46 | type=semver,pattern=v{{major}}.{{minor}} 47 | 48 | - name: Set up Go 49 | uses: actions/setup-go@v5 50 | with: 51 | go-version: 1.22.1 52 | 53 | - name: Download Go modules 54 | run: go mod download 55 | 56 | - name: Set up QEMU 57 | uses: docker/setup-qemu-action@v3 58 | 59 | - name: Set up Docker Buildx 60 | uses: docker/setup-buildx-action@v3 61 | 62 | - name: Login to DockerHub 63 | uses: docker/login-action@v3 64 | with: 65 | username: ${{ secrets.DOCKERHUB_USERNAME }} 66 | password: ${{ secrets.DOCKERHUB_TOKEN }} 67 | 68 | - name: Build and push 69 | uses: docker/build-push-action@v6 70 | with: 71 | context: . 72 | file: ./build/egress/Dockerfile 73 | push: true 74 | platforms: linux/amd64,linux/arm64 75 | tags: ${{ steps.docker-md.outputs.tags }} 76 | labels: ${{ steps.docker-md.outputs.labels }} 77 | -------------------------------------------------------------------------------- /.github/workflows/publish-gstreamer-base.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_call: 3 | inputs: 4 | version: 5 | required: true 6 | type: string 7 | buildjet-runs-on: 8 | required: true 9 | type: string 10 | arch: 11 | required: true 12 | type: string 13 | secrets: 14 | DOCKERHUB_USERNAME: 15 | required: true 16 | DOCKERHUB_TOKEN: 17 | required: true 18 | env: 19 | GST_VERSION: "${{ inputs.version }}" 20 | LIBNICE_VERSION: "0.1.21" 21 | 22 | jobs: 23 | base-gstreamer-build: 24 | runs-on: ${{ inputs.buildjet-runs-on }} 25 | 26 | steps: 27 | - name: Checkout code 28 | uses: actions/checkout@v4 29 | 30 | - name: Set up Docker Buildx 31 | uses: docker/setup-buildx-action@v3 32 | 33 | - name: Login to Docker Hub 34 | uses: docker/login-action@v3 35 | with: 36 | username: ${{ secrets.DOCKERHUB_USERNAME }} 37 | password: ${{ secrets.DOCKERHUB_TOKEN }} 38 | 39 | - name: Build and push base 40 | uses: docker/build-push-action@v6 41 | with: 42 | context: ./build/gstreamer 43 | push: true 44 | build-args: | 45 | GSTREAMER_VERSION=${{ env.GST_VERSION }} 46 | LIBNICE_VERSION=${{ env.LIBNICE_VERSION }} 47 | file: ./build/gstreamer/Dockerfile-base 48 | tags: livekit/gstreamer:${{ env.GST_VERSION }}-base-${{ inputs.arch }} 49 | 50 | - name: Build and push dev 51 | uses: docker/build-push-action@v6 52 | with: 53 | context: ./build/gstreamer 54 | push: true 55 | build-args: | 56 | GSTREAMER_VERSION=${{ env.GST_VERSION }} 57 | LIBNICE_VERSION=${{ env.LIBNICE_VERSION }} 58 | file: ./build/gstreamer/Dockerfile-dev 59 | tags: livekit/gstreamer:${{ env.GST_VERSION }}-dev-${{ inputs.arch }} 60 | 61 | - name: Build and push prod 62 | uses: docker/build-push-action@v6 63 | with: 64 | context: ./build/gstreamer 65 | push: true 66 | build-args: | 67 | GSTREAMER_VERSION=${{ env.GST_VERSION }} 68 | LIBNICE_VERSION=${{ env.LIBNICE_VERSION }} 69 | file: ./build/gstreamer/Dockerfile-prod 70 | tags: livekit/gstreamer:${{ env.GST_VERSION }}-prod-${{ inputs.arch }} 71 | 72 | - name: Build and push prod RS 73 | uses: docker/build-push-action@v6 74 | with: 75 | context: ./build/gstreamer 76 | push: true 77 | build-args: | 78 | GSTREAMER_VERSION=${{ env.GST_VERSION }} 79 | LIBNICE_VERSION=${{ env.LIBNICE_VERSION }} 80 | file: ./build/gstreamer/Dockerfile-prod-rs 81 | tags: livekit/gstreamer:${{ env.GST_VERSION }}-prod-rs-${{ inputs.arch }} 82 | -------------------------------------------------------------------------------- /.github/workflows/publish-gstreamer.yaml: -------------------------------------------------------------------------------- 1 | name: Publish GStreamer 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: "GStreamer version to publish (e.g. 1.24.4)" 8 | required: true 9 | type: string 10 | 11 | jobs: 12 | gstreamer-build-amd64: 13 | uses: ./.github/workflows/publish-gstreamer-base.yaml 14 | with: 15 | version: ${{ inputs.version }} 16 | buildjet-runs-on: buildjet-8vcpu-ubuntu-2204 17 | arch: amd64 18 | secrets: 19 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} 20 | DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} 21 | 22 | gstreamer-build-arm64: 23 | uses: ./.github/workflows/publish-gstreamer-base.yaml 24 | with: 25 | version: ${{ inputs.version }} 26 | buildjet-runs-on: namespace-profile-arm-16 27 | arch: arm64 28 | secrets: 29 | DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} 30 | DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} 31 | 32 | tag-gstreamer-build: 33 | needs: [gstreamer-build-amd64, gstreamer-build-arm64] 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Checkout code 37 | uses: actions/checkout@v4 38 | 39 | - name: Set up Docker Buildx 40 | uses: docker/setup-buildx-action@v3 41 | 42 | - name: Login to Docker Hub 43 | uses: docker/login-action@v3 44 | with: 45 | username: ${{ secrets.DOCKERHUB_USERNAME }} 46 | password: ${{ secrets.DOCKERHUB_TOKEN }} 47 | 48 | - name: Run tag script 49 | run: ./build/gstreamer/tag.sh ${{ inputs.version }} 50 | -------------------------------------------------------------------------------- /.github/workflows/publish-template-sdk.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Publish Template SDK 16 | on: 17 | push: 18 | tags: 19 | - "template*" 20 | 21 | jobs: 22 | deploy: 23 | runs-on: ubuntu-latest 24 | defaults: 25 | run: 26 | working-directory: ./template-sdk 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: pnpm/action-setup@v2 30 | with: 31 | version: 8 32 | - name: Use Node.js 18 33 | uses: actions/setup-node@v4 34 | with: 35 | node-version: 18 36 | cache: "pnpm" 37 | cache-dependency-path: ./template-sdk/pnpm-lock.yaml 38 | 39 | - name: Install Dependencies 40 | run: pnpm install 41 | 42 | - name: Build 43 | run: pnpm build 44 | 45 | - name: Publish to npm 46 | run: | 47 | npm config set '//registry.npmjs.org/:_authToken' $NPM_TOKEN 48 | npm publish 49 | env: 50 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 51 | -------------------------------------------------------------------------------- /.github/workflows/publish-template.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Publish Templates 16 | on: 17 | push: 18 | branches: [main] 19 | paths: 20 | - build/template/Dockerfile 21 | - template-default/** 22 | - template-sdk/** 23 | 24 | jobs: 25 | docker: 26 | runs-on: buildjet-8vcpu-ubuntu-2204 27 | steps: 28 | - uses: actions/checkout@v4 29 | 30 | - name: Docker metadata 31 | id: docker-md 32 | uses: docker/metadata-action@v5 33 | with: 34 | images: livekit/egress-templates 35 | tags: | 36 | type=sha 37 | type=raw,value=latest,enable={{is_default_branch}} 38 | 39 | - name: Set up Docker Buildx 40 | uses: docker/setup-buildx-action@v3 41 | 42 | - name: Login to DockerHub 43 | uses: docker/login-action@v3 44 | with: 45 | username: ${{ secrets.DOCKERHUB_USERNAME }} 46 | password: ${{ secrets.DOCKERHUB_TOKEN }} 47 | 48 | - name: Build and push 49 | uses: docker/build-push-action@v6 50 | with: 51 | context: . 52 | file: ./build/template/Dockerfile 53 | push: true 54 | platforms: linux/amd64,linux/arm64 55 | tags: ${{ steps.docker-md.outputs.tags }} 56 | labels: ${{ steps.docker-md.outputs.labels }} 57 | -------------------------------------------------------------------------------- /.github/workflows/test-integration.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Integration Test 16 | on: 17 | workflow_dispatch: 18 | pull_request: 19 | branches: [ main ] 20 | paths: 21 | - build/chrome/** 22 | - build/egress/** 23 | - build/gstreamer/** 24 | - build/test/** 25 | - cmd/** 26 | - pkg/** 27 | - test/** 28 | - go.mod 29 | 30 | jobs: 31 | build: 32 | runs-on: buildjet-8vcpu-ubuntu-2204 33 | outputs: 34 | image: ${{ steps.docker-md.outputs.tags }} 35 | steps: 36 | - uses: actions/checkout@v4 37 | with: 38 | lfs: true 39 | 40 | - uses: actions/cache@v4 41 | with: 42 | path: | 43 | ~/go/pkg/mod 44 | ~/go/bin 45 | ~/.cache 46 | key: egress-integration-${{ hashFiles('**/go.sum') }} 47 | restore-keys: egress-integration 48 | 49 | - name: Docker metadata 50 | id: docker-md 51 | uses: docker/metadata-action@v5 52 | with: 53 | images: livekit/egress-integration 54 | tags: | 55 | type=sha 56 | 57 | - name: Set up Go 58 | uses: actions/setup-go@v5 59 | with: 60 | go-version: 1.22.1 61 | 62 | - name: Download Go modules 63 | run: go mod download 64 | 65 | - name: Login to DockerHub 66 | uses: docker/login-action@v3 67 | with: 68 | username: ${{ secrets.DOCKERHUB_USERNAME }} 69 | password: ${{ secrets.DOCKERHUB_TOKEN }} 70 | 71 | - name: Build and push 72 | uses: docker/build-push-action@v6 73 | with: 74 | context: . 75 | file: ./build/test/Dockerfile 76 | push: true 77 | platforms: linux/amd64 78 | tags: ${{ steps.docker-md.outputs.tags }} 79 | labels: ${{ steps.docker-md.outputs.labels }} 80 | 81 | test: 82 | needs: build 83 | strategy: 84 | fail-fast: false 85 | matrix: 86 | integration_type: [file, stream, segments, images, multi, edge] 87 | runs-on: buildjet-8vcpu-ubuntu-2204 88 | steps: 89 | - uses: shogo82148/actions-setup-redis@v1 90 | with: 91 | redis-version: '6.x' 92 | auto-start: true 93 | - run: redis-cli ping 94 | 95 | - name: Run tests 96 | env: 97 | IMAGE: ${{needs.build.outputs.image}} 98 | run: | 99 | docker run --rm \ 100 | --network host \ 101 | -e GITHUB_WORKFLOW=1 \ 102 | -e EGRESS_CONFIG_STRING="$(echo ${{ secrets.EGRESS_CONFIG_STRING }} | base64 -d)" \ 103 | -e INTEGRATION_TYPE="${{ matrix.integration_type }}" \ 104 | -e S3_UPLOAD="$(echo ${{ secrets.S3_UPLOAD }} | base64 -d)" \ 105 | -e GCP_UPLOAD="$(echo ${{ secrets.GCP_UPLOAD }} | base64 -d)" \ 106 | ${{ env.IMAGE }} 107 | -------------------------------------------------------------------------------- /.github/workflows/test-template.yaml: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | name: Template Test 16 | on: 17 | workflow_dispatch: 18 | pull_request: 19 | branches: [main] 20 | paths: 21 | - build/template/Dockerfile 22 | - template-default/** 23 | - template-sdk/** 24 | 25 | defaults: 26 | run: 27 | working-directory: template-default 28 | 29 | jobs: 30 | test: 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - uses: pnpm/action-setup@v2 35 | with: 36 | version: 8 37 | - name: Use Node.js 18 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: 18 41 | cache: "pnpm" 42 | cache-dependency-path: ./template-default/pnpm-lock.yaml 43 | 44 | - run: pnpm install 45 | - run: pnpm build 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | .DS_Store 3 | 4 | .github/workflows/config.yaml 5 | build/plugins/ 6 | test/output/* 7 | test/*.yaml 8 | !test/config-sample.yaml 9 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | Copyright 2023 LiveKit, Inc. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Copyright 2023 LiveKit, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | 17 | if ! command -v mage &> /dev/null 18 | then 19 | pushd /tmp 20 | git clone https://github.com/magefile/mage 21 | cd mage 22 | go run bootstrap.go 23 | rm -rf /tmp/mage 24 | popd 25 | fi 26 | 27 | if ! command -v mage &> /dev/null 28 | then 29 | echo "Ensure `go env GOPATH`/bin is in your \$PATH" 30 | exit 1 31 | fi 32 | 33 | go mod download 34 | -------------------------------------------------------------------------------- /build/chrome/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | FROM ubuntu:24.04 16 | 17 | RUN mkdir /chrome-installer 18 | COPY output/arm64 /chrome-installer/arm64 19 | COPY output/amd64 /chrome-installer/amd64 20 | COPY install-chrome /chrome-installer/install-chrome 21 | -------------------------------------------------------------------------------- /build/chrome/README.md: -------------------------------------------------------------------------------- 1 | # Chrome installer 2 | 3 | This dockerfile is used to install chrome on ubuntu amd64 and arm64. 4 | 5 | There is no official or available arm64 build with H264 support, so we needed to compile it from source. 6 | 7 | ## Usage 8 | 9 | To install chrome, add the following to your dockerfile: 10 | 11 | ```dockerfile 12 | ARG TARGETPLATFORM 13 | COPY --from=livekit/chrome-installer:124.0.6367.201 /chrome-installer /chrome-installer 14 | RUN /chrome-installer/install-chrome "$TARGETPLATFORM" 15 | ENV PATH=${PATH}:/chrome 16 | ENV CHROME_DEVEL_SANDBOX=/usr/local/sbin/chrome-devel-sandbox 17 | ``` 18 | 19 | ## Compilation 20 | 21 | It must be cross compiled from an amd64 builder. This build takes multiple hours, even on fast machines. 22 | 23 | Relevant docs: 24 | * [Build instructions](https://chromium.googlesource.com/chromium/src/+/main/docs/linux/build_instructions.md) 25 | * [Cross compiling](https://chromium.googlesource.com/chromium/src/+/main/docs/linux/chromium_arm.md) 26 | 27 | ### Requirements 28 | 29 | * 64-bit Intel machine (x86_64) 30 | * Ubuntu 22.04 LTS 31 | * 64+ CPU cores 32 | * 128GB+ RAM 33 | * 100GB+ disk space 34 | -------------------------------------------------------------------------------- /build/chrome/install-chrome: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | 4 | if [ "$1" = "linux/arm64" ] 5 | then 6 | apt-get update 7 | apt-get install -y \ 8 | ca-certificates \ 9 | libasound2t64 \ 10 | libatk-bridge2.0-0 \ 11 | libatk1.0-0 \ 12 | libc6 \ 13 | libcairo2 \ 14 | libcups2 \ 15 | libdbus-1-3 \ 16 | libexpat1 \ 17 | libfontconfig1 \ 18 | libgbm1 \ 19 | libgcc1 \ 20 | libglib2.0-0 \ 21 | libnspr4 \ 22 | libnss3 \ 23 | libpango-1.0-0 \ 24 | libpangocairo-1.0-0 \ 25 | libstdc++6 \ 26 | libx11-6 \ 27 | libx11-xcb1 \ 28 | libxcb1 \ 29 | libxcomposite1 \ 30 | libxcursor1 \ 31 | libxdamage1 \ 32 | libxext6 \ 33 | libxfixes3 \ 34 | libxi6 \ 35 | libxrandr2 \ 36 | libxrender1 \ 37 | libxss1 \ 38 | libxtst6 39 | chmod +x /chrome-installer/arm64/chromedriver-mac-arm64/chromedriver 40 | mv -f /chrome-installer/arm64/chromedriver-mac-arm64/chromedriver /usr/local/bin/chromedriver 41 | mv /chrome-installer/arm64/ /chrome 42 | cp /chrome/chrome_sandbox /usr/local/sbin/chrome-devel-sandbox 43 | chown root:root /usr/local/sbin/chrome-devel-sandbox 44 | chmod 4755 /usr/local/sbin/chrome-devel-sandbox 45 | else 46 | apt-get install -y /chrome-installer/amd64/google-chrome-stable_amd64.deb 47 | chmod +x /chrome-installer/amd64/chromedriver-linux64/chromedriver 48 | mv -f /chrome-installer/amd64/chromedriver-linux64/chromedriver /usr/local/bin/chromedriver 49 | fi 50 | 51 | rm -rf /chrome-installer 52 | -------------------------------------------------------------------------------- /build/chrome/scripts/amd64.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xeuo pipefail 3 | 4 | wget https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_"$1"-1_amd64.deb 5 | mkdir -p "$HOME/output/amd64" 6 | mv google-chrome-stable_"$1"-1_amd64.deb "$HOME/output/amd64/google-chrome-stable_amd64.deb" 7 | -------------------------------------------------------------------------------- /build/chrome/scripts/arm64.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xeuo pipefail 3 | 4 | sudo apt-get update 5 | sudo apt-get install -y \ 6 | apt-utils \ 7 | build-essential \ 8 | curl \ 9 | git \ 10 | python3 \ 11 | sudo \ 12 | zip 13 | git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git 14 | export PATH="$PATH:$HOME/depot_tools" 15 | mkdir chromium 16 | cd chromium || exit 17 | fetch --nohooks --no-history chromium 18 | echo 'solutions = [ 19 | { 20 | "name": "src", 21 | "url": "https://chromium.googlesource.com/chromium/src.git", 22 | "managed": False, 23 | "custom_deps": {}, 24 | "custom_vars": { 25 | "checkout_pgo_profiles": True, 26 | }, 27 | "target_cpu": "arm64", 28 | }, 29 | ]' | tee '.gclient' > /dev/null 30 | cd src || exit 31 | git fetch --tags 32 | git checkout -b stable "$1" 33 | gclient sync -D --with_branch_heads --with_tags 34 | ./build/install-build-deps.sh 35 | ./build/linux/sysroot_scripts/install-sysroot.py --arch=arm64 36 | gclient runhooks 37 | gn gen out/default --args='target_cpu="arm64" proprietary_codecs=true ffmpeg_branding="Chrome" enable_nacl=false is_debug=false symbol_level=0 v8_symbol_level=0 dcheck_always_on=false is_official_build=true' 38 | autoninja -C out/default chrome chrome_sandbox 39 | cd out/default || exit 40 | mkdir -p "$HOME/output/arm64/locales" 41 | mv locales/en-US.pak "$HOME/output/arm64/locales/" 42 | mv chrome \ 43 | chrome-wrapper \ 44 | chrome_100_percent.pak \ 45 | chrome_200_percent.pak \ 46 | chrome_crashpad_handler \ 47 | chrome_sandbox \ 48 | headless_lib_data.pak \ 49 | headless_lib_strings.pak \ 50 | icudtl.dat \ 51 | libEGL.so \ 52 | libGLESv2.so \ 53 | resources.pak \ 54 | snapshot_blob.bin \ 55 | v8_context_snapshot.bin \ 56 | "$HOME/output/arm64/" 57 | -------------------------------------------------------------------------------- /build/chrome/scripts/driver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xeuo pipefail 3 | 4 | wget https://storage.googleapis.com/chrome-for-testing-public/"$1"/linux64/chromedriver-linux64.zip 5 | unzip chromedriver-linux64.zip -d "$HOME/output/amd64" 6 | wget https://storage.googleapis.com/chrome-for-testing-public/"$1"/mac-arm64/chromedriver-mac-arm64.zip 7 | unzip chromedriver-mac-arm64.zip -d "$HOME/output/arm64" 8 | -------------------------------------------------------------------------------- /build/chrome/scripts/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xeuo pipefail 3 | 4 | useradd -m -d /home/chrome -s /bin/bash chrome 5 | mkdir /home/chrome/.ssh 6 | cp /root/.ssh/authorized_keys /home/chrome/.ssh/authorized_keys 7 | chown -R chrome:chrome /home/chrome/.ssh 8 | chmod 700 /home/chrome/.ssh 9 | chmod 600 /home/chrome/.ssh/authorized_keys 10 | adduser chrome sudo 11 | sed -i '54i chrome ALL=(ALL:ALL) NOPASSWD: ALL' /etc/sudoers 12 | echo "ClientAliveInterval 60" >> /etc/ssh/sshd_config 13 | echo "ClientAliveCountMax 3" >> /etc/ssh/sshd_config 14 | systemctl restart ssh 15 | -------------------------------------------------------------------------------- /build/egress/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | FROM livekit/gstreamer:1.22.12-dev 16 | 17 | ARG TARGETPLATFORM 18 | ARG TARGETARCH 19 | ENV TARGETARCH=${TARGETARCH} 20 | ENV TARGETPLATFORM=${TARGETPLATFORM} 21 | 22 | WORKDIR /workspace 23 | 24 | # install go 25 | RUN wget https://go.dev/dl/go1.22.1.linux-${TARGETARCH}.tar.gz && \ 26 | rm -rf /usr/local/go && \ 27 | tar -C /usr/local -xzf go1.22.1.linux-${TARGETARCH}.tar.gz 28 | ENV PATH="/usr/local/go/bin:${PATH}" 29 | 30 | # download go modules 31 | COPY go.mod . 32 | COPY go.sum . 33 | RUN go mod download 34 | 35 | # copy source 36 | COPY cmd/ cmd/ 37 | COPY pkg/ pkg/ 38 | COPY version/ version/ 39 | 40 | # copy templates 41 | COPY --from=livekit/egress-templates workspace/build/ cmd/server/templates/ 42 | # delete .map files 43 | RUN find cmd/server/templates/ -name *.map | xargs rm 44 | 45 | # build 46 | RUN CGO_ENABLED=1 GOOS=linux GOARCH=${TARGETARCH} GO111MODULE=on GODEBUG=disablethp=1 go build -a -o egress ./cmd/server 47 | 48 | # install tini 49 | ENV TINI_VERSION v0.19.0 50 | 51 | ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini-${TARGETARCH} /tini 52 | RUN chmod +x /tini 53 | 54 | FROM livekit/gstreamer:1.22.12-prod 55 | 56 | ARG TARGETPLATFORM 57 | 58 | # install deps 59 | RUN apt-get update && \ 60 | apt-get install -y \ 61 | curl \ 62 | fonts-noto \ 63 | gnupg \ 64 | pulseaudio \ 65 | unzip \ 66 | wget \ 67 | xvfb \ 68 | gstreamer1.0-plugins-base- 69 | 70 | # install chrome 71 | COPY --from=livekit/chrome-installer:125.0.6422.141 /chrome-installer /chrome-installer 72 | RUN /chrome-installer/install-chrome "$TARGETPLATFORM" 73 | 74 | # clean up 75 | RUN rm -rf /var/lib/apt/lists/* 76 | 77 | # create egress user 78 | RUN useradd -ms /bin/bash -g root -G sudo,pulse,pulse-access egress 79 | RUN mkdir -p home/egress/tmp home/egress/.cache/xdgr && \ 80 | chown -R egress /home/egress 81 | 82 | # copy files 83 | COPY --from=0 /workspace/egress /bin/ 84 | COPY --from=0 /tini /tini 85 | COPY build/egress/entrypoint.sh / 86 | 87 | # run 88 | USER egress 89 | ENV PATH=${PATH}:/chrome 90 | ENV XDG_RUNTIME_DIR=/home/egress/.cache/xdgr 91 | ENV CHROME_DEVEL_SANDBOX=/usr/local/sbin/chrome-devel-sandbox 92 | ENTRYPOINT ["/entrypoint.sh"] 93 | -------------------------------------------------------------------------------- /build/egress/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright 2023 LiveKit, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | set -euxo pipefail 17 | 18 | # Clean out tmp 19 | rm -rf /home/egress/tmp/* 20 | 21 | # Start pulseaudio 22 | rm -rf /var/run/pulse /var/lib/pulse /home/egress/.config/pulse /home/egress/.cache/xdgr/pulse 23 | pulseaudio -D --verbose --exit-idle-time=-1 --disallow-exit 24 | 25 | # Run egress service 26 | exec /tini -- egress 27 | -------------------------------------------------------------------------------- /build/gstreamer/Dockerfile-base: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 2 | 3 | ARG GSTREAMER_VERSION 4 | 5 | ARG LIBNICE_VERSION 6 | 7 | COPY install-dependencies / 8 | 9 | RUN /install-dependencies 10 | 11 | ENV PATH=/root/.cargo/bin:$PATH 12 | 13 | RUN for lib in gstreamer gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav; \ 14 | do \ 15 | wget https://gstreamer.freedesktop.org/src/$lib/$lib-$GSTREAMER_VERSION.tar.xz && \ 16 | tar -xf $lib-$GSTREAMER_VERSION.tar.xz && \ 17 | rm $lib-$GSTREAMER_VERSION.tar.xz && \ 18 | mv $lib-$GSTREAMER_VERSION $lib; \ 19 | done 20 | 21 | # rust plugins are apparently only realeased on gitlab 22 | 23 | RUN wget https://gitlab.freedesktop.org/gstreamer/gst-plugins-rs/-/archive/gstreamer-$GSTREAMER_VERSION/gst-plugins-rs-gstreamer-$GSTREAMER_VERSION.tar.gz && \ 24 | tar xfz gst-plugins-rs-gstreamer-$GSTREAMER_VERSION.tar.gz && \ 25 | rm gst-plugins-rs-gstreamer-$GSTREAMER_VERSION.tar.gz && \ 26 | mv gst-plugins-rs-gstreamer-$GSTREAMER_VERSION gst-plugins-rs 27 | 28 | RUN wget https://libnice.freedesktop.org/releases/libnice-$LIBNICE_VERSION.tar.gz && \ 29 | tar xfz libnice-$LIBNICE_VERSION.tar.gz && \ 30 | rm libnice-$LIBNICE_VERSION.tar.gz && \ 31 | mv libnice-$LIBNICE_VERSION libnice 32 | -------------------------------------------------------------------------------- /build/gstreamer/Dockerfile-dev: -------------------------------------------------------------------------------- 1 | ARG GSTREAMER_VERSION 2 | 3 | FROM livekit/gstreamer:${GSTREAMER_VERSION}-base-${TARGETARCH} 4 | 5 | ENV DEBUG=true 6 | ENV OPTIMIZATIONS=false 7 | 8 | COPY compile / 9 | COPY compile-rs / 10 | 11 | RUN /compile 12 | RUN /compile-rs 13 | 14 | FROM ubuntu:24.04 15 | 16 | COPY install-dependencies / 17 | 18 | RUN /install-dependencies 19 | 20 | COPY --from=0 /compiled-binaries / 21 | -------------------------------------------------------------------------------- /build/gstreamer/Dockerfile-prod: -------------------------------------------------------------------------------- 1 | ARG GSTREAMER_VERSION 2 | 3 | FROM livekit/gstreamer:${GSTREAMER_VERSION}-base-${TARGETARCH} 4 | 5 | ENV DEBUG=false 6 | ENV OPTIMIZATIONS=true 7 | 8 | COPY compile / 9 | 10 | RUN /compile 11 | 12 | FROM ubuntu:24.04 13 | 14 | RUN apt-get update && \ 15 | apt-get dist-upgrade -y && \ 16 | apt-get install -y --no-install-recommends \ 17 | bubblewrap \ 18 | ca-certificates \ 19 | iso-codes \ 20 | ladspa-sdk \ 21 | liba52-0.7.4 \ 22 | libaa1 \ 23 | libaom3 \ 24 | libass9 \ 25 | libavcodec60 \ 26 | libavfilter9 \ 27 | libavformat60 \ 28 | libavutil58 \ 29 | libbs2b0 \ 30 | libbz2-1.0 \ 31 | libcaca0 \ 32 | libcap2 \ 33 | libchromaprint1 \ 34 | libcurl3-gnutls \ 35 | libdca0 \ 36 | libde265-0 \ 37 | libdv4 \ 38 | libdvdnav4 \ 39 | libdvdread8 \ 40 | libdw1 \ 41 | libegl1 \ 42 | libepoxy0 \ 43 | libfaac0 \ 44 | libfaad2 \ 45 | libfdk-aac2 \ 46 | libflite1 \ 47 | libgbm1 \ 48 | libgcrypt20 \ 49 | libgl1 \ 50 | libgles1 \ 51 | libgles2 \ 52 | libglib2.0-0 \ 53 | libgme0 \ 54 | libgmp10 \ 55 | libgsl27 \ 56 | libgsm1 \ 57 | libgudev-1.0-0 \ 58 | libharfbuzz-icu0 \ 59 | libjpeg8 \ 60 | libkate1 \ 61 | liblcms2-2 \ 62 | liblilv-0-0 \ 63 | libmjpegutils-2.1-0 \ 64 | libmodplug1 \ 65 | libmp3lame0 \ 66 | libmpcdec6 \ 67 | libmpeg2-4 \ 68 | libmpg123-0 \ 69 | libofa0 \ 70 | libogg0 \ 71 | libopencore-amrnb0 \ 72 | libopencore-amrwb0 \ 73 | libopenexr-3-1-30 \ 74 | libopenjp2-7 \ 75 | libopus0 \ 76 | liborc-0.4-0 \ 77 | libpango-1.0-0 \ 78 | libpng16-16 \ 79 | librsvg2-2 \ 80 | librtmp1 \ 81 | libsbc1 \ 82 | libseccomp2 \ 83 | libshout3 \ 84 | libsndfile1 \ 85 | libsoundtouch1 \ 86 | libsoup2.4-1 \ 87 | libspandsp2 \ 88 | libspeex1 \ 89 | libsrt1.5-openssl \ 90 | libsrtp2-1 \ 91 | libssl3 \ 92 | libtag1v5 \ 93 | libtheora0 \ 94 | libtwolame0 \ 95 | libunwind8 \ 96 | libvisual-0.4-0 \ 97 | libvo-aacenc0 \ 98 | libvo-amrwbenc0 \ 99 | libvorbis0a \ 100 | libvpx9 \ 101 | libvulkan1 \ 102 | libwavpack1 \ 103 | libwebp7 \ 104 | libwebpdemux2 \ 105 | libwebpmux3 \ 106 | libwebrtc-audio-processing1 \ 107 | libwildmidi2 \ 108 | libwoff1 \ 109 | libx264-164 \ 110 | libx265-199 \ 111 | libxkbcommon0 \ 112 | libxslt1.1 \ 113 | libzbar0 \ 114 | libzvbi0 \ 115 | mjpegtools \ 116 | xdg-dbus-proxy && \ 117 | apt-get clean && \ 118 | rm -rf /var/lib/apt/lists/* 119 | 120 | COPY --from=0 /compiled-binaries / 121 | -------------------------------------------------------------------------------- /build/gstreamer/Dockerfile-prod-rs: -------------------------------------------------------------------------------- 1 | ARG GSTREAMER_VERSION 2 | 3 | FROM livekit/gstreamer:${GSTREAMER_VERSION}-base-${TARGETARCH} 4 | 5 | FROM livekit/gstreamer:${GSTREAMER_VERSION}-dev-${TARGETARCH} 6 | 7 | COPY --from=0 /gst-plugins-rs /gst-plugins-rs 8 | 9 | ENV DEBUG=false 10 | ENV OPTIMIZATIONS=true 11 | ENV PATH=/root/.cargo/bin:$PATH 12 | 13 | COPY compile-rs / 14 | 15 | RUN /compile-rs 16 | 17 | FROM livekit/gstreamer:${GSTREAMER_VERSION}-prod-${TARGETARCH} 18 | 19 | COPY --from=1 /compiled-binaries / 20 | -------------------------------------------------------------------------------- /build/gstreamer/compile: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | 4 | for repo in gstreamer libnice gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugly gst-libav; do 5 | pushd $repo 6 | 7 | opts="-D prefix=/usr" 8 | 9 | if [[ $repo != "libnice" ]]; then 10 | opts="$opts -D tests=disabled -D doc=disabled" 11 | fi 12 | 13 | if [[ $repo == "gstreamer" ]]; then 14 | opts="$opts -D examples=disabled -D introspection=disabled" 15 | elif [[ $repo == "gst-plugins-base" ]]; then 16 | opts="$opts -D examples=disabled -D introspection=disabled -D qt5=disabled" 17 | elif [[ $repo == "gst-plugins-good" ]]; then 18 | opts="$opts -D examples=disabled -D pulse=enabled -D qt5=disabled" 19 | elif [[ $repo == "gst-plugins-bad" ]]; then 20 | opts="$opts -D gpl=enabled -D examples=disabled -D introspection=disabled" 21 | elif [[ $repo == "gst-plugins-ugly" ]]; then 22 | opts="$opts -D gpl=enabled" 23 | fi 24 | 25 | if [[ $DEBUG == 'true' ]]; then 26 | if [[ $OPTIMIZATIONS == 'true' ]]; then 27 | opts="$opts -D buildtype=debugoptimized" 28 | else 29 | opts="$opts -D buildtype=debug" 30 | fi 31 | else 32 | opts="$opts -D buildtype=release -D b_lto=true" 33 | fi 34 | 35 | meson build $opts 36 | 37 | # This is needed for other plugins to be built properly 38 | ninja -C build install 39 | # This is where we'll grab build artifacts from 40 | DESTDIR=/compiled-binaries ninja -C build install 41 | popd 42 | done 43 | 44 | gst-inspect-1.0 45 | -------------------------------------------------------------------------------- /build/gstreamer/compile-rs: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | 4 | for repo in gst-plugins-rs; do 5 | pushd $repo 6 | 7 | # strip binaries in debug mode 8 | mv Cargo.toml Cargo.toml.old 9 | sed s,'\[profile.release\]','[profile.release]\nstrip="debuginfo"', Cargo.toml.old > Cargo.toml 10 | cargo update -p time 11 | 12 | opts="-D prefix=/usr -D tests=disabled -D doc=disabled" 13 | 14 | if [[ $DEBUG == 'true' ]]; then 15 | if [[ $OPTIMIZATIONS == 'true' ]]; then 16 | opts="$opts -D buildtype=debugoptimized" 17 | else 18 | opts="$opts -D buildtype=debug" 19 | fi 20 | else 21 | opts="$opts -D buildtype=release -D b_lto=true" 22 | fi 23 | 24 | meson build $opts 25 | 26 | # This is needed for other plugins to be built properly 27 | ninja -C build install 28 | # This is where we'll grab build artifacts from 29 | DESTDIR=/compiled-binaries ninja -C build install 30 | popd 31 | done 32 | 33 | gst-inspect-1.0 34 | -------------------------------------------------------------------------------- /build/gstreamer/install-dependencies: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euxo pipefail 3 | 4 | export DEBIAN_FRONTEND=noninteractive 5 | 6 | apt-get update 7 | apt-get dist-upgrade -y 8 | apt-get install -y --no-install-recommends \ 9 | bison \ 10 | bubblewrap \ 11 | ca-certificates \ 12 | cmake \ 13 | curl \ 14 | flex \ 15 | flite1-dev \ 16 | gcc \ 17 | gettext \ 18 | git \ 19 | gperf \ 20 | iso-codes \ 21 | liba52-0.7.4-dev \ 22 | libaa1-dev \ 23 | libaom-dev \ 24 | libass-dev \ 25 | libavcodec-dev \ 26 | libavfilter-dev \ 27 | libavformat-dev \ 28 | libavutil-dev \ 29 | libbs2b-dev \ 30 | libbz2-dev \ 31 | libcaca-dev \ 32 | libcap-dev \ 33 | libchromaprint-dev \ 34 | libcurl4-gnutls-dev \ 35 | libdca-dev \ 36 | libde265-dev \ 37 | libdrm-dev \ 38 | libdv4-dev \ 39 | libdvdnav-dev \ 40 | libdvdread-dev \ 41 | libdw-dev \ 42 | libepoxy-dev \ 43 | libfaac-dev \ 44 | libfaad-dev \ 45 | libfdk-aac-dev \ 46 | libgbm-dev \ 47 | libgcrypt20-dev \ 48 | libgirepository1.0-dev \ 49 | libgl-dev \ 50 | libgles-dev \ 51 | libglib2.0-dev \ 52 | libgme-dev \ 53 | libgmp-dev \ 54 | libgsl-dev \ 55 | libgsm1-dev \ 56 | libgudev-1.0-dev \ 57 | libjpeg-dev \ 58 | libkate-dev \ 59 | liblcms2-dev \ 60 | liblilv-dev \ 61 | libmjpegtools-dev \ 62 | libmodplug-dev \ 63 | libmp3lame-dev \ 64 | libmpcdec-dev \ 65 | libmpeg2-4-dev \ 66 | libmpg123-dev \ 67 | libofa0-dev \ 68 | libogg-dev \ 69 | libopencore-amrnb-dev \ 70 | libopencore-amrwb-dev \ 71 | libopenexr-dev \ 72 | libopenjp2-7-dev \ 73 | libopus-dev \ 74 | liborc-0.4-dev \ 75 | libpango1.0-dev \ 76 | libpng-dev \ 77 | libpulse-dev \ 78 | librsvg2-dev \ 79 | librtmp-dev \ 80 | libsbc-dev \ 81 | libseccomp-dev \ 82 | libshout3-dev \ 83 | libsndfile1-dev \ 84 | libsoundtouch-dev \ 85 | libsoup2.4-dev \ 86 | libspandsp-dev \ 87 | libspeex-dev \ 88 | libsrt-gnutls-dev \ 89 | libsrtp2-dev \ 90 | libssl-dev \ 91 | libtag1-dev \ 92 | libtheora-dev \ 93 | libtwolame-dev \ 94 | libudev-dev \ 95 | libunwind-dev \ 96 | libvisual-0.4-dev \ 97 | libvo-aacenc-dev \ 98 | libvo-amrwbenc-dev \ 99 | libvorbis-dev \ 100 | libvpx-dev \ 101 | libvulkan-dev \ 102 | libwavpack-dev \ 103 | libwebp-dev \ 104 | libwebrtc-audio-processing-dev \ 105 | libwildmidi-dev \ 106 | libwoff-dev \ 107 | libx264-dev \ 108 | libx265-dev \ 109 | libxkbcommon-dev \ 110 | libxslt1-dev \ 111 | libzbar-dev \ 112 | libzvbi-dev \ 113 | meson \ 114 | ninja-build \ 115 | python3 \ 116 | ruby \ 117 | wget \ 118 | xdg-dbus-proxy 119 | apt-get clean 120 | rm -rf /var/lib/apt/lists/* 121 | 122 | # install rust 123 | curl -o install-rustup.sh --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs 124 | sh install-rustup.sh -y 125 | source "$HOME/.cargo/env" 126 | cargo install cargo-c 127 | rm -rf install-rustup.sh 128 | -------------------------------------------------------------------------------- /build/gstreamer/tag.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | image_suffix=(base dev prod prod-rs) 4 | archs=(amd64 arm64) 5 | gst_version=$1 6 | 7 | for suffix in ${image_suffix[*]} 8 | do 9 | digests=() 10 | for arch in ${archs[*]} 11 | do 12 | digest=`docker manifest inspect livekit/gstreamer:$gst_version-$suffix-$arch | jq ".manifests[] | select(.platform.architecture == \"$arch\").digest"` 13 | # remove quotes 14 | digest=${digest:1:$[${#digest}-2]} 15 | digests+=($digest) 16 | done 17 | 18 | manifests="" 19 | for digest in ${digests[*]} 20 | do 21 | manifests+=" livekit/gstreamer@$digest" 22 | done 23 | 24 | docker manifest create livekit/gstreamer:$gst_version-$suffix$manifests 25 | docker manifest push livekit/gstreamer:$gst_version-$suffix 26 | done 27 | -------------------------------------------------------------------------------- /build/template/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | FROM ubuntu:24.04 16 | 17 | WORKDIR /workspace 18 | 19 | RUN apt update 20 | RUN apt install -y curl 21 | RUN curl -sL https://deb.nodesource.com/setup_18.x | bash - 22 | RUN apt update 23 | RUN apt install -y nodejs 24 | RUN npm install -g pnpm 25 | 26 | # copy templates 27 | COPY template-default/ . 28 | 29 | # build 30 | RUN pnpm install 31 | RUN pnpm build 32 | -------------------------------------------------------------------------------- /build/test/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2023 LiveKit, Inc. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | FROM livekit/gstreamer:1.22.12-dev 16 | 17 | WORKDIR /workspace 18 | 19 | ARG TARGETPLATFORM 20 | 21 | # install go 22 | RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then GOARCH=arm64; else GOARCH=amd64; fi && \ 23 | wget https://go.dev/dl/go1.22.1.linux-${GOARCH}.tar.gz && \ 24 | rm -rf /usr/local/go && \ 25 | tar -C /usr/local -xzf go1.22.1.linux-${GOARCH}.tar.gz 26 | ENV PATH="/usr/local/go/bin:${PATH}" 27 | 28 | # download samples 29 | RUN apt-get update && apt-get install -y git-lfs 30 | RUN git clone --depth 1 https://github.com/livekit/media-samples.git 31 | RUN cd media-samples && git lfs pull 32 | 33 | # download go modules 34 | COPY go.mod . 35 | COPY go.sum . 36 | RUN go mod download 37 | 38 | # copy source 39 | COPY cmd/ cmd/ 40 | COPY pkg/ pkg/ 41 | COPY test/ test/ 42 | COPY version/ version/ 43 | 44 | # copy templates 45 | COPY --from=livekit/egress-templates workspace/build/ cmd/server/templates/ 46 | COPY --from=livekit/egress-templates workspace/build/ test/templates/ 47 | 48 | # build (service tests will need to launch the handler) 49 | RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then GOARCH=arm64; else GOARCH=amd64; fi && \ 50 | CGO_ENABLED=1 GOOS=linux GOARCH=${GOARCH} GO111MODULE=on GODEBUG=disablethp=1 go build -a -o egress ./cmd/server 51 | 52 | RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then GOARCH=arm64; else GOARCH=amd64; fi && \ 53 | CGO_ENABLED=1 GOOS=linux GOARCH=${GOARCH} GO111MODULE=on go test -c -v -race --tags=integration ./test 54 | 55 | 56 | FROM livekit/gstreamer:1.22.12-prod 57 | 58 | ARG TARGETPLATFORM 59 | 60 | # install deps 61 | RUN apt-get update && \ 62 | apt-get install -y \ 63 | curl \ 64 | ffmpeg \ 65 | fonts-noto \ 66 | gnupg \ 67 | pulseaudio \ 68 | unzip \ 69 | wget \ 70 | xvfb \ 71 | gstreamer1.0-plugins-base- 72 | 73 | # install go 74 | RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then GOARCH=arm64; else GOARCH=amd64; fi && \ 75 | wget https://go.dev/dl/go1.22.1.linux-${GOARCH}.tar.gz && \ 76 | rm -rf /usr/local/go && \ 77 | tar -C /usr/local -xzf go1.22.1.linux-${GOARCH}.tar.gz 78 | ENV PATH="/usr/local/go/bin:${PATH}" 79 | 80 | # install chrome 81 | COPY --from=livekit/chrome-installer:125.0.6422.141 /chrome-installer /chrome-installer 82 | RUN /chrome-installer/install-chrome "$TARGETPLATFORM" 83 | 84 | # clean up 85 | RUN rm -rf /var/lib/apt/lists/* 86 | 87 | # install rtsp server 88 | RUN if [ "$TARGETPLATFORM" = "linux/arm64" ]; then ARCH=arm64v8; else ARCH=amd64; fi && \ 89 | wget https://github.com/bluenviron/mediamtx/releases/download/v1.8.1/mediamtx_v1.8.1_linux_${ARCH}.tar.gz && \ 90 | tar -zxvf mediamtx_v1.8.1_linux_${ARCH}.tar.gz && \ 91 | rm mediamtx_v1.8.1_linux_${ARCH}.tar.gz && \ 92 | sed -i 's_record: no_record: yes_g' mediamtx.yml && \ 93 | sed -i 's_recordPath: ./recordings/%path/_recordPath: /out/output/stream-_g' mediamtx.yml 94 | 95 | # create egress user 96 | RUN useradd -ms /bin/bash -g root -G sudo,pulse,pulse-access egress 97 | RUN mkdir -p home/egress/tmp home/egress/.cache/xdgr && \ 98 | chown -R egress /home/egress 99 | 100 | # copy files 101 | COPY test/ /workspace/test/ 102 | COPY --from=0 /workspace/egress /bin/ 103 | COPY --from=0 /workspace/test.test . 104 | COPY --from=0 /workspace/media-samples /media-samples 105 | COPY build/test/entrypoint.sh . 106 | 107 | # run tests 108 | USER egress 109 | ENV PATH=${PATH}:/chrome 110 | ENV XDG_RUNTIME_DIR=/home/egress/.cache/xdgr 111 | ENV CHROME_DEVEL_SANDBOX=/usr/local/sbin/chrome-devel-sandbox 112 | ENTRYPOINT ["./entrypoint.sh"] 113 | -------------------------------------------------------------------------------- /build/test/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Copyright 2023 LiveKit, Inc. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | set -exo pipefail 17 | 18 | # Start pulseaudio 19 | rm -rf /var/run/pulse /var/lib/pulse /home/egress/.config/pulse /home/egress/.cache/xdgr/pulse 20 | pulseaudio -D --verbose --exit-idle-time=-1 --disallow-exit 21 | 22 | # Run RTSP server 23 | ./mediamtx > /dev/null 2>&1 & 24 | 25 | # Run tests 26 | if [[ -z ${GITHUB_WORKFLOW+x} ]]; then 27 | exec ./test.test -test.v -test.timeout 30m 28 | else 29 | go install github.com/gotesttools/gotestfmt/v2/cmd/gotestfmt@latest 30 | exec go tool test2json -p egress ./test.test -test.v -test.timeout 30m 2>&1 | "$HOME"/go/bin/gotestfmt 31 | fi 32 | -------------------------------------------------------------------------------- /cmd/server/http.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package main 16 | 17 | import ( 18 | "net/http" 19 | 20 | "github.com/livekit/egress/pkg/server" 21 | "github.com/livekit/protocol/logger" 22 | ) 23 | 24 | type httpHandler struct { 25 | svc *server.Server 26 | } 27 | 28 | func (h *httpHandler) ServeHTTP(w http.ResponseWriter, _ *http.Request) { 29 | info, err := h.svc.Status() 30 | if err != nil { 31 | logger.Errorw("failed to read status", err) 32 | } 33 | 34 | w.Header().Set("Content-Type", "application/json") 35 | _, _ = w.Write(info) 36 | } 37 | -------------------------------------------------------------------------------- /pkg/config/config_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package config 16 | 17 | import ( 18 | "os" 19 | "testing" 20 | 21 | "github.com/stretchr/testify/require" 22 | 23 | "github.com/livekit/protocol/livekit" 24 | ) 25 | 26 | func TestSegmentNaming(t *testing.T) { 27 | t.Cleanup(func() { 28 | _ = os.RemoveAll("conf_test/") 29 | }) 30 | 31 | for _, test := range []struct { 32 | filenamePrefix string 33 | playlistName string 34 | livePlaylistName string 35 | expectedStorageDir string 36 | expectedPlaylistFilename string 37 | expectedLivePlaylistFilename string 38 | expectedSegmentPrefix string 39 | }{ 40 | { 41 | filenamePrefix: "", playlistName: "playlist", livePlaylistName: "", 42 | expectedStorageDir: "", expectedPlaylistFilename: "playlist.m3u8", expectedLivePlaylistFilename: "", expectedSegmentPrefix: "playlist", 43 | }, 44 | { 45 | filenamePrefix: "", playlistName: "conf_test/playlist", livePlaylistName: "conf_test/live_playlist", 46 | expectedStorageDir: "conf_test/", expectedPlaylistFilename: "playlist.m3u8", expectedLivePlaylistFilename: "live_playlist.m3u8", expectedSegmentPrefix: "playlist", 47 | }, 48 | { 49 | filenamePrefix: "filename", playlistName: "", livePlaylistName: "live_playlist2.m3u8", 50 | expectedStorageDir: "", expectedPlaylistFilename: "filename.m3u8", expectedLivePlaylistFilename: "live_playlist2.m3u8", expectedSegmentPrefix: "filename", 51 | }, 52 | { 53 | filenamePrefix: "filename", playlistName: "playlist", livePlaylistName: "", 54 | expectedStorageDir: "", expectedPlaylistFilename: "playlist.m3u8", expectedLivePlaylistFilename: "", expectedSegmentPrefix: "filename", 55 | }, 56 | { 57 | filenamePrefix: "filename", playlistName: "conf_test/", livePlaylistName: "", 58 | expectedStorageDir: "conf_test/", expectedPlaylistFilename: "filename.m3u8", expectedLivePlaylistFilename: "", expectedSegmentPrefix: "filename", 59 | }, 60 | { 61 | filenamePrefix: "filename", playlistName: "conf_test/playlist", livePlaylistName: "", 62 | expectedStorageDir: "conf_test/", expectedPlaylistFilename: "playlist.m3u8", expectedLivePlaylistFilename: "", expectedSegmentPrefix: "filename", 63 | }, 64 | { 65 | filenamePrefix: "conf_test/", playlistName: "playlist", livePlaylistName: "", 66 | expectedStorageDir: "conf_test/", expectedPlaylistFilename: "playlist.m3u8", expectedLivePlaylistFilename: "", expectedSegmentPrefix: "playlist", 67 | }, 68 | { 69 | filenamePrefix: "conf_test/filename", playlistName: "playlist", livePlaylistName: "", 70 | expectedStorageDir: "conf_test/", expectedPlaylistFilename: "playlist.m3u8", expectedLivePlaylistFilename: "", expectedSegmentPrefix: "filename", 71 | }, 72 | { 73 | filenamePrefix: "conf_test/filename", playlistName: "conf_test/playlist", livePlaylistName: "", 74 | expectedStorageDir: "conf_test/", expectedPlaylistFilename: "playlist.m3u8", expectedLivePlaylistFilename: "", expectedSegmentPrefix: "filename", 75 | }, 76 | { 77 | filenamePrefix: "conf_test_2/filename", playlistName: "conf_test/playlist", livePlaylistName: "", 78 | expectedStorageDir: "conf_test/", expectedPlaylistFilename: "playlist.m3u8", expectedLivePlaylistFilename: "", expectedSegmentPrefix: "conf_test_2/filename", 79 | }, 80 | } { 81 | p := &PipelineConfig{Info: &livekit.EgressInfo{EgressId: "egress_ID"}} 82 | o, err := p.getSegmentConfig(&livekit.SegmentedFileOutput{ 83 | FilenamePrefix: test.filenamePrefix, 84 | PlaylistName: test.playlistName, 85 | LivePlaylistName: test.livePlaylistName, 86 | }) 87 | require.NoError(t, err) 88 | 89 | require.Equal(t, test.expectedStorageDir, o.StorageDir) 90 | require.Equal(t, test.expectedPlaylistFilename, o.PlaylistFilename) 91 | require.Equal(t, test.expectedLivePlaylistFilename, o.LivePlaylistFilename) 92 | require.Equal(t, test.expectedSegmentPrefix, o.SegmentPrefix) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /pkg/config/encoding.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package config 16 | 17 | import ( 18 | "github.com/livekit/egress/pkg/errors" 19 | "github.com/livekit/egress/pkg/types" 20 | "github.com/livekit/protocol/livekit" 21 | ) 22 | 23 | func (p *PipelineConfig) applyPreset(preset livekit.EncodingOptionsPreset) { 24 | switch preset { 25 | case livekit.EncodingOptionsPreset_H264_720P_30: 26 | p.Width = 1280 27 | p.Height = 720 28 | p.Framerate = 30 29 | p.VideoBitrate = 3000 30 | 31 | case livekit.EncodingOptionsPreset_H264_720P_60: 32 | p.Width = 1280 33 | p.Height = 720 34 | p.Framerate = 60 35 | p.VideoBitrate = 4500 36 | 37 | case livekit.EncodingOptionsPreset_H264_1080P_30: 38 | p.Width = 1920 39 | p.Height = 1080 40 | p.Framerate = 30 41 | p.VideoBitrate = 4500 42 | 43 | case livekit.EncodingOptionsPreset_H264_1080P_60: 44 | p.Width = 1920 45 | p.Height = 1080 46 | p.Framerate = 60 47 | p.VideoBitrate = 6000 48 | 49 | case livekit.EncodingOptionsPreset_PORTRAIT_H264_720P_30: 50 | p.Width = 720 51 | p.Height = 1280 52 | p.Framerate = 30 53 | p.VideoBitrate = 3000 54 | 55 | case livekit.EncodingOptionsPreset_PORTRAIT_H264_720P_60: 56 | p.Width = 720 57 | p.Height = 1280 58 | p.Framerate = 60 59 | p.VideoBitrate = 4500 60 | 61 | case livekit.EncodingOptionsPreset_PORTRAIT_H264_1080P_30: 62 | p.Width = 1080 63 | p.Height = 1920 64 | p.Framerate = 30 65 | p.VideoBitrate = 4500 66 | 67 | case livekit.EncodingOptionsPreset_PORTRAIT_H264_1080P_60: 68 | p.Width = 1080 69 | p.Height = 1920 70 | p.Framerate = 60 71 | p.VideoBitrate = 6000 72 | } 73 | } 74 | 75 | func (p *PipelineConfig) applyAdvanced(advanced *livekit.EncodingOptions) error { 76 | // audio 77 | switch advanced.AudioCodec { 78 | case livekit.AudioCodec_OPUS: 79 | p.AudioOutCodec = types.MimeTypeOpus 80 | case livekit.AudioCodec_AAC: 81 | p.AudioOutCodec = types.MimeTypeAAC 82 | } 83 | 84 | if advanced.AudioBitrate != 0 { 85 | p.AudioBitrate = advanced.AudioBitrate 86 | } 87 | if advanced.AudioFrequency != 0 { 88 | p.AudioFrequency = advanced.AudioFrequency 89 | } 90 | 91 | // video 92 | switch advanced.VideoCodec { 93 | case livekit.VideoCodec_H264_BASELINE: 94 | p.VideoOutCodec = types.MimeTypeH264 95 | p.VideoProfile = types.ProfileBaseline 96 | 97 | case livekit.VideoCodec_H264_MAIN: 98 | p.VideoOutCodec = types.MimeTypeH264 99 | 100 | case livekit.VideoCodec_H264_HIGH: 101 | p.VideoOutCodec = types.MimeTypeH264 102 | p.VideoProfile = types.ProfileHigh 103 | } 104 | 105 | if advanced.Width > 0 { 106 | if advanced.Width < 16 || advanced.Width%2 == 1 { 107 | return errors.ErrInvalidInput("width") 108 | } 109 | p.Width = advanced.Width 110 | } 111 | 112 | if advanced.Height > 0 { 113 | if advanced.Height < 16 || advanced.Height%2 == 1 { 114 | return errors.ErrInvalidInput("height") 115 | } 116 | p.Height = advanced.Height 117 | } 118 | 119 | switch advanced.Depth { 120 | case 0: 121 | case 8, 16, 24: 122 | p.Depth = advanced.Depth 123 | default: 124 | return errors.ErrInvalidInput("depth") 125 | } 126 | 127 | if advanced.Framerate != 0 { 128 | p.Framerate = advanced.Framerate 129 | } 130 | if advanced.VideoBitrate != 0 { 131 | p.VideoBitrate = advanced.VideoBitrate 132 | } 133 | if advanced.KeyFrameInterval != 0 { 134 | p.KeyFrameInterval = advanced.KeyFrameInterval 135 | } 136 | 137 | return nil 138 | } 139 | -------------------------------------------------------------------------------- /pkg/config/manifest.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package config 16 | 17 | import ( 18 | "bytes" 19 | "encoding/json" 20 | "sync" 21 | "time" 22 | ) 23 | 24 | type Manifest struct { 25 | EgressID string `json:"egress_id,omitempty"` 26 | RoomID string `json:"room_id,omitempty"` 27 | RoomName string `json:"room_name,omitempty"` 28 | Url string `json:"url,omitempty"` 29 | StartedAt int64 `json:"started_at,omitempty"` 30 | EndedAt int64 `json:"ended_at,omitempty"` 31 | PublisherIdentity string `json:"publisher_identity,omitempty"` 32 | TrackID string `json:"track_id,omitempty"` 33 | TrackKind string `json:"track_kind,omitempty"` 34 | TrackSource string `json:"track_source,omitempty"` 35 | AudioTrackID string `json:"audio_track_id,omitempty"` 36 | VideoTrackID string `json:"video_track_id,omitempty"` 37 | 38 | mu sync.Mutex 39 | Files []*File `json:"files,omitempty"` 40 | Playlists []*Playlist `json:"playlists,omitempty"` 41 | Images []*Image `json:"images,omitempty"` 42 | } 43 | 44 | type File struct { 45 | Filename string `json:"filename,omitempty"` 46 | Location string `json:"location,omitempty"` 47 | } 48 | 49 | type Playlist struct { 50 | mu sync.Mutex 51 | Location string `json:"location,omitempty"` 52 | Segments []*Segment `json:"segments,omitempty"` 53 | } 54 | 55 | type Segment struct { 56 | Filename string `json:"filename,omitempty"` 57 | Location string `json:"location,omitempty"` 58 | } 59 | 60 | type Image struct { 61 | Filename string `json:"filename,omitempty"` 62 | Timestamp time.Time `json:"timestamp,omitempty"` 63 | Location string `json:"location,omitempty"` 64 | } 65 | 66 | func (p *PipelineConfig) initManifest() { 67 | if p.shouldCreateManifest() { 68 | p.Manifest = &Manifest{ 69 | EgressID: p.Info.EgressId, 70 | RoomID: p.Info.RoomId, 71 | RoomName: p.Info.RoomName, 72 | Url: p.WebUrl, 73 | StartedAt: p.Info.StartedAt, 74 | PublisherIdentity: p.Identity, 75 | TrackID: p.TrackID, 76 | TrackKind: p.TrackKind, 77 | TrackSource: p.TrackSource, 78 | AudioTrackID: p.AudioTrackID, 79 | VideoTrackID: p.VideoTrackID, 80 | } 81 | } 82 | } 83 | 84 | func (p *PipelineConfig) shouldCreateManifest() bool { 85 | if p.BackupConfig != nil { 86 | return true 87 | } 88 | if fc := p.GetFileConfig(); fc != nil && !fc.DisableManifest { 89 | return true 90 | } 91 | if sc := p.GetSegmentConfig(); sc != nil && !sc.DisableManifest { 92 | return true 93 | } 94 | for _, ic := range p.GetImageConfigs() { 95 | if !ic.DisableManifest { 96 | return true 97 | } 98 | } 99 | return false 100 | } 101 | 102 | func (m *Manifest) AddFile(filename, location string) { 103 | m.mu.Lock() 104 | m.Files = append(m.Files, &File{ 105 | Filename: filename, 106 | Location: location, 107 | }) 108 | m.mu.Unlock() 109 | } 110 | 111 | func (m *Manifest) AddPlaylist() *Playlist { 112 | p := &Playlist{} 113 | 114 | m.mu.Lock() 115 | m.Playlists = append(m.Playlists, p) 116 | m.mu.Unlock() 117 | 118 | return p 119 | } 120 | 121 | func (p *Playlist) UpdateLocation(location string) { 122 | p.mu.Lock() 123 | p.Location = location 124 | p.mu.Unlock() 125 | } 126 | 127 | func (p *Playlist) AddSegment(filename, location string) { 128 | p.mu.Lock() 129 | p.Segments = append(p.Segments, &Segment{ 130 | Filename: filename, 131 | Location: location, 132 | }) 133 | p.mu.Unlock() 134 | } 135 | 136 | func (m *Manifest) AddImage(filename string, ts time.Time, location string) { 137 | m.mu.Lock() 138 | m.Images = append(m.Images, &Image{ 139 | Filename: filename, 140 | Timestamp: ts, 141 | Location: location, 142 | }) 143 | m.mu.Unlock() 144 | } 145 | 146 | func (m *Manifest) Close(endedAt int64) ([]byte, error) { 147 | m.EndedAt = endedAt 148 | 149 | buf := bytes.NewBuffer(nil) 150 | enc := json.NewEncoder(buf) 151 | enc.SetEscapeHTML(false) 152 | if err := enc.Encode(m); err != nil { 153 | return nil, err 154 | } 155 | 156 | return buf.Bytes(), nil 157 | } 158 | -------------------------------------------------------------------------------- /pkg/config/output_image.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package config 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | "path" 21 | "time" 22 | 23 | "github.com/livekit/egress/pkg/errors" 24 | "github.com/livekit/egress/pkg/types" 25 | "github.com/livekit/protocol/livekit" 26 | "github.com/livekit/protocol/utils" 27 | ) 28 | 29 | type ImageConfig struct { 30 | outputConfig 31 | 32 | Id string // Used internally to map a gst Bin/element back to a sink and as part of the path 33 | 34 | ImagesInfo *livekit.ImagesInfo 35 | LocalDir string 36 | StorageDir string 37 | ImagePrefix string 38 | ImageSuffix livekit.ImageFileSuffix 39 | ImageExtension types.FileExtension 40 | 41 | DisableManifest bool 42 | StorageConfig *StorageConfig 43 | 44 | CaptureInterval uint32 45 | Width int32 46 | Height int32 47 | ImageOutCodec types.MimeType 48 | } 49 | 50 | func (p *PipelineConfig) GetImageConfigs() []*ImageConfig { 51 | o, _ := p.Outputs[types.EgressTypeImages] 52 | 53 | var configs []*ImageConfig 54 | for _, c := range o { 55 | configs = append(configs, c.(*ImageConfig)) 56 | } 57 | 58 | return configs 59 | } 60 | 61 | func (p *PipelineConfig) getImageConfig(images *livekit.ImageOutput) (*ImageConfig, error) { 62 | outCodec, outputType, err := getMimeTypes(images.ImageCodec) 63 | if err != nil { 64 | return nil, err 65 | } 66 | 67 | sc, err := p.getStorageConfig(images) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | filenamePrefix := clean(images.FilenamePrefix) 73 | conf := &ImageConfig{ 74 | outputConfig: outputConfig{ 75 | OutputType: outputType, 76 | }, 77 | 78 | Id: utils.NewGuid(""), 79 | ImagesInfo: &livekit.ImagesInfo{ 80 | FilenamePrefix: filenamePrefix, 81 | }, 82 | ImagePrefix: filenamePrefix, 83 | ImageSuffix: images.FilenameSuffix, 84 | DisableManifest: images.DisableManifest, 85 | StorageConfig: sc, 86 | CaptureInterval: images.CaptureInterval, 87 | Width: images.Width, 88 | Height: images.Height, 89 | ImageOutCodec: outCodec, 90 | } 91 | 92 | if conf.CaptureInterval == 0 { 93 | // 10s by default 94 | conf.CaptureInterval = 10 95 | } 96 | 97 | // Set default dimensions for RoomComposite and Web. For all SDKs input, default will be 98 | // set from the track dimensions 99 | switch p.Info.Request.(type) { 100 | case *livekit.EgressInfo_RoomComposite, *livekit.EgressInfo_Web: 101 | if conf.Width == 0 { 102 | conf.Width = p.Width 103 | } 104 | if conf.Height == 0 { 105 | conf.Height = p.Height 106 | } 107 | } 108 | 109 | // filename 110 | err = conf.updatePrefix(p) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | return conf, nil 116 | } 117 | 118 | func (o *ImageConfig) updatePrefix(p *PipelineConfig) error { 119 | identifier, replacements := p.getFilenameInfo() 120 | 121 | o.ImagePrefix = stringReplace(o.ImagePrefix, replacements) 122 | o.ImagesInfo.FilenamePrefix = stringReplace(o.ImagesInfo.FilenamePrefix, replacements) 123 | o.ImageExtension = types.FileExtensionForOutputType[o.OutputType] 124 | 125 | imagesDir, imagesPrefix := path.Split(o.ImagePrefix) 126 | o.StorageDir = imagesDir 127 | 128 | // ensure playlistName 129 | if imagesPrefix == "" { 130 | imagesPrefix = fmt.Sprintf("%s-%s", identifier, time.Now().Format("2006-01-02T150405")) 131 | } 132 | 133 | // update config 134 | o.ImagePrefix = imagesPrefix 135 | 136 | // Prepend the configuration base directory and the egress Id, and slug to prevent conflict if 137 | // there is more than one image output 138 | // os.ModeDir creates a directory with mode 000 when mapping the directory outside the container 139 | o.LocalDir = path.Join(p.TmpDir, o.Id) 140 | if err := os.MkdirAll(o.LocalDir, 0755); err != nil { 141 | return err 142 | } 143 | 144 | return nil 145 | } 146 | 147 | func getMimeTypes(imageCodec livekit.ImageCodec) (types.MimeType, types.OutputType, error) { 148 | switch imageCodec { 149 | case livekit.ImageCodec_IC_DEFAULT, livekit.ImageCodec_IC_JPEG: 150 | return types.MimeTypeJPEG, types.OutputTypeJPEG, nil 151 | default: 152 | return "", "", errors.ErrNoCompatibleCodec 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /pkg/config/output_stream.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package config 16 | 17 | import ( 18 | "sync" 19 | 20 | "github.com/livekit/egress/pkg/types" 21 | "github.com/livekit/protocol/livekit" 22 | "github.com/livekit/protocol/logger" 23 | ) 24 | 25 | type StreamConfig struct { 26 | outputConfig 27 | 28 | // url -> Stream 29 | Streams sync.Map 30 | 31 | twitchTemplate string 32 | } 33 | 34 | type Stream struct { 35 | Name string // gstreamer stream ID 36 | ParsedUrl string // parsed/validated url 37 | RedactedUrl string // url with stream key removed 38 | StreamID string // stream ID used by rtmpconnection 39 | StreamInfo *livekit.StreamInfo 40 | } 41 | 42 | func (p *PipelineConfig) GetStreamConfig() *StreamConfig { 43 | o, ok := p.Outputs[types.EgressTypeStream] 44 | if !ok || len(o) == 0 { 45 | return nil 46 | } 47 | return o[0].(*StreamConfig) 48 | } 49 | 50 | func (p *PipelineConfig) GetWebsocketConfig() *StreamConfig { 51 | o, ok := p.Outputs[types.EgressTypeWebsocket] 52 | if !ok || len(o) == 0 { 53 | return nil 54 | } 55 | return o[0].(*StreamConfig) 56 | } 57 | 58 | func (p *PipelineConfig) getStreamConfig(outputType types.OutputType, urls []string) (*StreamConfig, error) { 59 | conf := &StreamConfig{ 60 | outputConfig: outputConfig{OutputType: outputType}, 61 | } 62 | 63 | for _, rawUrl := range urls { 64 | _, err := conf.AddStream(rawUrl, outputType) 65 | if err != nil { 66 | return nil, err 67 | } 68 | } 69 | 70 | switch outputType { 71 | case types.OutputTypeRTMP: 72 | p.AudioOutCodec = types.MimeTypeAAC 73 | p.VideoOutCodec = types.MimeTypeH264 74 | 75 | case types.OutputTypeSRT: 76 | p.AudioOutCodec = types.MimeTypeAAC 77 | p.VideoOutCodec = types.MimeTypeH264 78 | 79 | case types.OutputTypeRaw: 80 | p.AudioOutCodec = types.MimeTypeRawAudio 81 | } 82 | 83 | return conf, nil 84 | } 85 | 86 | func (s *Stream) UpdateEndTime(endedAt int64) { 87 | s.StreamInfo.EndedAt = endedAt 88 | if s.StreamInfo.StartedAt == 0 { 89 | if s.StreamInfo.Status != livekit.StreamInfo_FAILED { 90 | logger.Warnw("stream missing start time", nil, "url", s.RedactedUrl) 91 | } 92 | s.StreamInfo.StartedAt = endedAt 93 | } else { 94 | s.StreamInfo.Duration = endedAt - s.StreamInfo.StartedAt 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /pkg/config/urls_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package config 16 | 17 | import ( 18 | "regexp" 19 | "strings" 20 | "testing" 21 | 22 | "github.com/stretchr/testify/require" 23 | 24 | "github.com/livekit/egress/pkg/types" 25 | ) 26 | 27 | func TestValidateUrl(t *testing.T) { 28 | var twitchUpdated = regexp.MustCompile("rtmps://(.*).contribute.live-video.net/app/streamkey") 29 | var twitchRedacted = regexp.MustCompile("rtmps://(.*).contribute.live-video.net/app/\\{str\\.\\.\\.key}") 30 | 31 | o := &StreamConfig{} 32 | 33 | for _, test := range []struct { 34 | url string 35 | twitch bool 36 | parsed string 37 | redacted string 38 | }{ 39 | { 40 | url: "mux://streamkey", 41 | parsed: "rtmps://global-live.mux.com:443/app/streamkey", 42 | redacted: "rtmps://global-live.mux.com:443/app/{str...key}", 43 | }, 44 | { 45 | url: "twitch://streamkey", 46 | twitch: true, 47 | }, 48 | { 49 | url: "rtmp://fake.contribute.live-video.net/app/streamkey", 50 | twitch: true, 51 | }, 52 | { 53 | url: "rtmp://localhost:1935/live/streamkey", 54 | parsed: "rtmp://localhost:1935/live/streamkey", 55 | redacted: "rtmp://localhost:1935/live/{str...key}", 56 | }, 57 | { 58 | url: "rtmps://localhost:1935/live/streamkey", 59 | parsed: "rtmps://localhost:1935/live/streamkey", 60 | redacted: "rtmps://localhost:1935/live/{str...key}", 61 | }, 62 | } { 63 | parsed, redacted, streamID, err := o.ValidateUrl(test.url, types.OutputTypeRTMP) 64 | require.NoError(t, err) 65 | require.NotEmpty(t, streamID) 66 | 67 | if test.twitch { 68 | require.NotEmpty(t, twitchUpdated.FindString(parsed), parsed) 69 | require.NotEmpty(t, twitchRedacted.FindString(redacted), redacted) 70 | } else { 71 | require.Equal(t, test.parsed, parsed) 72 | require.Equal(t, test.redacted, redacted) 73 | } 74 | } 75 | } 76 | 77 | func TestGetUrl(t *testing.T) { 78 | o := &StreamConfig{} 79 | require.NoError(t, o.updateTwitchTemplate()) 80 | 81 | parsedTwitchUrl := strings.ReplaceAll(o.twitchTemplate, "{stream_key}", "streamkey") 82 | urls := []string{ 83 | "rtmps://global-live.mux.com:443/app/streamkey", 84 | parsedTwitchUrl, 85 | parsedTwitchUrl, 86 | "rtmp://localhost:1935/live/streamkey", 87 | } 88 | 89 | for _, url := range []string{urls[0], urls[1], urls[3]} { 90 | _, err := o.AddStream(url, types.OutputTypeRTMP) 91 | require.NoError(t, err) 92 | } 93 | 94 | for i, rawUrl := range []string{ 95 | "mux://streamkey", 96 | "twitch://streamkey", 97 | "rtmp://any.contribute.live-video.net/app/streamkey", 98 | "rtmp://localhost:1935/live/streamkey", 99 | } { 100 | stream, err := o.GetStream(rawUrl) 101 | require.NoError(t, err) 102 | require.Equal(t, urls[i], stream.ParsedUrl) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /pkg/gstreamer/builder.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package gstreamer 16 | 17 | import ( 18 | "github.com/go-gst/go-gst/gst" 19 | 20 | "github.com/livekit/egress/pkg/errors" 21 | ) 22 | 23 | func BuildQueue(name string, latency uint64, leaky bool) (*gst.Element, error) { 24 | queue, err := gst.NewElementWithName("queue", name) 25 | if err != nil { 26 | return nil, errors.ErrGstPipelineError(err) 27 | } 28 | if latency > 0 { 29 | if err = queue.SetProperty("max-size-time", latency); err != nil { 30 | return nil, errors.ErrGstPipelineError(err) 31 | } 32 | if err = queue.SetProperty("max-size-bytes", uint(0)); err != nil { 33 | return nil, errors.ErrGstPipelineError(err) 34 | } 35 | if err = queue.SetProperty("max-size-buffers", uint(0)); err != nil { 36 | return nil, errors.ErrGstPipelineError(err) 37 | } 38 | } 39 | if leaky { 40 | queue.SetArg("leaky", "downstream") 41 | } 42 | 43 | return queue, nil 44 | } 45 | -------------------------------------------------------------------------------- /pkg/gstreamer/callbacks.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package gstreamer 16 | 17 | import ( 18 | "sync" 19 | 20 | "github.com/livekit/egress/pkg/config" 21 | "github.com/livekit/egress/pkg/errors" 22 | ) 23 | 24 | type Callbacks struct { 25 | mu sync.RWMutex 26 | GstReady chan struct{} 27 | BuildReady chan struct{} 28 | 29 | // upstream callbacks 30 | onError func(error) 31 | onStop []func() error 32 | 33 | // source callbacks 34 | onTrackAdded []func(*config.TrackSource) 35 | onTrackMuted []func(string) 36 | onTrackUnmuted []func(string) 37 | onTrackRemoved []func(string) 38 | onEOSSent func() 39 | } 40 | 41 | func (c *Callbacks) SetOnError(f func(error)) { 42 | c.mu.Lock() 43 | c.onError = f 44 | c.mu.Unlock() 45 | } 46 | 47 | func (c *Callbacks) OnError(err error) { 48 | c.mu.RLock() 49 | onError := c.onError 50 | c.mu.RUnlock() 51 | 52 | if onError != nil { 53 | onError(err) 54 | } 55 | } 56 | 57 | func (c *Callbacks) AddOnStop(f func() error) { 58 | c.mu.Lock() 59 | c.onStop = append(c.onStop, f) 60 | c.mu.Unlock() 61 | } 62 | 63 | func (c *Callbacks) OnStop() error { 64 | c.mu.RLock() 65 | onStop := c.onStop 66 | c.mu.RUnlock() 67 | 68 | errArray := &errors.ErrArray{} 69 | for _, f := range onStop { 70 | errArray.Check(f()) 71 | } 72 | return errArray.ToError() 73 | } 74 | 75 | func (c *Callbacks) AddOnTrackAdded(f func(*config.TrackSource)) { 76 | c.mu.Lock() 77 | c.onTrackAdded = append(c.onTrackAdded, f) 78 | c.mu.Unlock() 79 | } 80 | 81 | func (c *Callbacks) OnTrackAdded(ts *config.TrackSource) { 82 | c.mu.RLock() 83 | onTrackAdded := c.onTrackAdded 84 | c.mu.RUnlock() 85 | 86 | for _, f := range onTrackAdded { 87 | f(ts) 88 | } 89 | } 90 | 91 | func (c *Callbacks) AddOnTrackMuted(f func(string)) { 92 | c.mu.Lock() 93 | c.onTrackMuted = append(c.onTrackMuted, f) 94 | c.mu.Unlock() 95 | } 96 | 97 | func (c *Callbacks) OnTrackMuted(trackID string) { 98 | c.mu.RLock() 99 | onTrackMuted := c.onTrackMuted 100 | c.mu.RUnlock() 101 | 102 | for _, f := range onTrackMuted { 103 | f(trackID) 104 | } 105 | } 106 | 107 | func (c *Callbacks) AddOnTrackUnmuted(f func(string)) { 108 | c.mu.Lock() 109 | c.onTrackUnmuted = append(c.onTrackUnmuted, f) 110 | c.mu.Unlock() 111 | } 112 | 113 | func (c *Callbacks) OnTrackUnmuted(trackID string) { 114 | c.mu.RLock() 115 | onTrackUnmuted := c.onTrackUnmuted 116 | c.mu.RUnlock() 117 | 118 | for _, f := range onTrackUnmuted { 119 | f(trackID) 120 | } 121 | } 122 | 123 | func (c *Callbacks) AddOnTrackRemoved(f func(string)) { 124 | c.mu.Lock() 125 | c.onTrackRemoved = append(c.onTrackRemoved, f) 126 | c.mu.Unlock() 127 | } 128 | 129 | func (c *Callbacks) OnTrackRemoved(trackID string) { 130 | c.mu.RLock() 131 | onTrackRemoved := c.onTrackRemoved 132 | c.mu.RUnlock() 133 | 134 | for _, f := range onTrackRemoved { 135 | f(trackID) 136 | } 137 | } 138 | 139 | func (c *Callbacks) SetOnEOSSent(f func()) { 140 | c.mu.Lock() 141 | c.onEOSSent = f 142 | c.mu.Unlock() 143 | } 144 | 145 | func (c *Callbacks) OnEOSSent() { 146 | c.mu.RLock() 147 | onEOSSent := c.onEOSSent 148 | c.mu.RUnlock() 149 | 150 | if onEOSSent != nil { 151 | onEOSSent() 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /pkg/gstreamer/pipeline.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package gstreamer 16 | 17 | import ( 18 | "time" 19 | 20 | "github.com/go-gst/go-glib/glib" 21 | "github.com/go-gst/go-gst/gst" 22 | 23 | "github.com/livekit/egress/pkg/errors" 24 | "github.com/livekit/protocol/logger" 25 | ) 26 | 27 | const ( 28 | stateChangeTimeout = time.Second * 15 29 | ) 30 | 31 | type Pipeline struct { 32 | *Bin 33 | 34 | loop *glib.MainLoop 35 | binsAdded bool 36 | elementsAdded bool 37 | } 38 | 39 | // A pipeline can have either elements or src and sink bins. If you add both you will get a wrong hierarchy error 40 | // Bins can contain both elements and src and sink bins 41 | func NewPipeline(name string, latency uint64, callbacks *Callbacks) (*Pipeline, error) { 42 | pipeline, err := gst.NewPipeline(name) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return &Pipeline{ 48 | Bin: &Bin{ 49 | Callbacks: callbacks, 50 | StateManager: &StateManager{}, 51 | pipeline: pipeline, 52 | bin: pipeline.Bin, 53 | latency: latency, 54 | queues: make(map[string]*gst.Element), 55 | }, 56 | loop: glib.NewMainLoop(glib.MainContextDefault(), false), 57 | }, nil 58 | } 59 | 60 | func (p *Pipeline) AddSourceBin(src *Bin) error { 61 | if p.elementsAdded { 62 | return errors.ErrWrongHierarchy 63 | } 64 | p.binsAdded = true 65 | return p.Bin.AddSourceBin(src) 66 | } 67 | 68 | func (p *Pipeline) AddSinkBin(sink *Bin) error { 69 | if p.elementsAdded { 70 | return errors.ErrWrongHierarchy 71 | } 72 | p.binsAdded = true 73 | return p.Bin.AddSinkBin(sink) 74 | } 75 | 76 | func (p *Pipeline) AddElement(e *gst.Element) error { 77 | if p.binsAdded { 78 | return errors.ErrWrongHierarchy 79 | } 80 | p.elementsAdded = true 81 | return p.Bin.AddElement(e) 82 | } 83 | 84 | func (p *Pipeline) AddElements(elements ...*gst.Element) error { 85 | if p.binsAdded { 86 | return errors.ErrWrongHierarchy 87 | } 88 | p.elementsAdded = true 89 | return p.Bin.AddElements(elements...) 90 | } 91 | 92 | func (p *Pipeline) Link() error { 93 | return p.link() 94 | } 95 | 96 | func (p *Pipeline) SetWatch(watch func(msg *gst.Message) bool) { 97 | p.pipeline.GetPipelineBus().AddWatch(watch) 98 | } 99 | 100 | func (p *Pipeline) SetState(state gst.State) error { 101 | p.mu.Lock() 102 | defer p.mu.Unlock() 103 | 104 | stateErr := make(chan error, 1) 105 | go func() { 106 | stateErr <- p.pipeline.SetState(state) 107 | }() 108 | 109 | select { 110 | case <-time.After(stateChangeTimeout): 111 | return errors.ErrPipelineFrozen 112 | case err := <-stateErr: 113 | if err != nil { 114 | return errors.ErrGstPipelineError(err) 115 | } 116 | } 117 | 118 | return nil 119 | } 120 | 121 | func (p *Pipeline) Run() error { 122 | if _, ok := p.UpgradeState(StateStarted); ok { 123 | if err := p.SetState(gst.StatePlaying); err != nil { 124 | return err 125 | } 126 | if _, ok = p.UpgradeState(StateRunning); ok { 127 | p.loop.Run() 128 | } 129 | } 130 | 131 | return nil 132 | } 133 | 134 | func (p *Pipeline) SendEOS() { 135 | old, ok := p.UpgradeState(StateEOS) 136 | if ok { 137 | if old >= StateRunning { 138 | p.sendEOS() 139 | } else { 140 | p.Stop() 141 | } 142 | } 143 | } 144 | 145 | func (p *Pipeline) Stop() { 146 | old, ok := p.UpgradeState(StateStopping) 147 | if !ok { 148 | return 149 | } 150 | 151 | if err := p.OnStop(); err != nil { 152 | p.OnError(err) 153 | } 154 | if err := p.SetState(gst.StateNull); err != nil { 155 | logger.Errorw("failed to set pipeline to null", err) 156 | } 157 | 158 | if old >= StateRunning { 159 | p.loop.Quit() 160 | } 161 | 162 | p.UpgradeState(StateFinished) 163 | } 164 | 165 | func (p *Pipeline) DebugBinToDotData(details gst.DebugGraphDetails) string { 166 | return p.pipeline.DebugBinToDotData(details) 167 | } 168 | -------------------------------------------------------------------------------- /pkg/gstreamer/state.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package gstreamer 16 | 17 | import ( 18 | "fmt" 19 | "sync" 20 | 21 | "github.com/livekit/protocol/logger" 22 | ) 23 | 24 | type State int 25 | 26 | const ( 27 | StateBuilding State = iota 28 | StateStarted 29 | StateRunning 30 | StateEOS 31 | StateStopping 32 | StateFinished 33 | ) 34 | 35 | type StateManager struct { 36 | lock sync.RWMutex 37 | state State 38 | } 39 | 40 | func (s *StateManager) GetState() State { 41 | s.lock.RLock() 42 | defer s.lock.RUnlock() 43 | 44 | return s.state 45 | } 46 | 47 | func (s *StateManager) GetStateLocked() State { 48 | return s.state 49 | } 50 | 51 | func (s *StateManager) LockState() { 52 | s.lock.Lock() 53 | } 54 | 55 | func (s *StateManager) UnlockState() { 56 | s.lock.Unlock() 57 | } 58 | 59 | func (s *StateManager) LockStateShared() { 60 | s.lock.RLock() 61 | } 62 | 63 | func (s *StateManager) UnlockStateShared() { 64 | s.lock.RUnlock() 65 | } 66 | 67 | func (s *StateManager) UpgradeState(state State) (State, bool) { 68 | s.lock.Lock() 69 | defer s.lock.Unlock() 70 | 71 | old := s.state 72 | if old >= state { 73 | return old, false 74 | } else { 75 | logger.Debugw(fmt.Sprintf("pipeline state %v -> %v", old, state)) 76 | s.state = state 77 | return old, true 78 | } 79 | } 80 | 81 | func (s State) String() string { 82 | switch s { 83 | case StateBuilding: 84 | return "building" 85 | case StateStarted: 86 | return "starting" 87 | case StateRunning: 88 | return "running" 89 | case StateEOS: 90 | return "eos" 91 | case StateStopping: 92 | return "stopping" 93 | case StateFinished: 94 | return "finished" 95 | default: 96 | return "unknown" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /pkg/handler/handler.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package handler 16 | 17 | import ( 18 | "context" 19 | "path" 20 | 21 | "github.com/frostbyte73/core" 22 | "google.golang.org/grpc" 23 | 24 | "github.com/livekit/egress/pkg/config" 25 | "github.com/livekit/egress/pkg/ipc" 26 | "github.com/livekit/egress/pkg/pipeline" 27 | "github.com/livekit/protocol/livekit" 28 | "github.com/livekit/protocol/logger" 29 | "github.com/livekit/protocol/rpc" 30 | "github.com/livekit/protocol/tracer" 31 | "github.com/livekit/psrpc" 32 | ) 33 | 34 | type Handler struct { 35 | ipc.UnimplementedEgressHandlerServer 36 | 37 | conf *config.PipelineConfig 38 | controller *pipeline.Controller 39 | rpcServer rpc.EgressHandlerServer 40 | ipcHandlerServer *grpc.Server 41 | ipcServiceClient ipc.EgressServiceClient 42 | initialized core.Fuse 43 | kill core.Fuse 44 | } 45 | 46 | func NewHandler(conf *config.PipelineConfig, bus psrpc.MessageBus) (*Handler, error) { 47 | ipcClient, err := ipc.NewServiceClient(path.Join(config.TmpDir, conf.NodeID)) 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | h := &Handler{ 53 | conf: conf, 54 | ipcHandlerServer: grpc.NewServer(), 55 | ipcServiceClient: ipcClient, 56 | } 57 | 58 | ipc.RegisterEgressHandlerServer(h.ipcHandlerServer, h) 59 | if err = ipc.StartHandlerListener(h.ipcHandlerServer, path.Join(config.TmpDir, conf.HandlerID)); err != nil { 60 | return nil, err 61 | } 62 | 63 | rpcServer, err := rpc.NewEgressHandlerServer(h, bus) 64 | if err != nil { 65 | return nil, err 66 | } 67 | if err = rpcServer.RegisterUpdateStreamTopic(conf.Info.EgressId); err != nil { 68 | return nil, err 69 | } 70 | if err = rpcServer.RegisterStopEgressTopic(conf.Info.EgressId); err != nil { 71 | return nil, err 72 | } 73 | h.rpcServer = rpcServer 74 | 75 | _, err = h.ipcServiceClient.HandlerReady(context.Background(), &ipc.HandlerReadyRequest{EgressId: conf.Info.EgressId}) 76 | if err != nil { 77 | logger.Errorw("failed to notify service", err) 78 | return nil, err 79 | } 80 | 81 | return h, nil 82 | } 83 | 84 | func (h *Handler) Run() { 85 | ctx, span := tracer.Start(context.Background(), "Handler.Run") 86 | defer span.End() 87 | 88 | defer func() { 89 | h.rpcServer.Shutdown() 90 | h.ipcHandlerServer.Stop() 91 | }() 92 | 93 | var err error 94 | h.controller, err = pipeline.New(context.Background(), h.conf, h.ipcServiceClient) 95 | h.initialized.Break() 96 | if err != nil { 97 | h.conf.Info.SetFailed(err) 98 | _, _ = h.ipcServiceClient.HandlerUpdate(context.Background(), h.conf.Info) 99 | return 100 | } 101 | 102 | // start egress 103 | res := h.controller.Run(ctx) 104 | m, err := h.GenerateMetrics(ctx) 105 | if err != nil { 106 | logger.Errorw("failed to generate handler metrics", err) 107 | } 108 | 109 | _, _ = h.ipcServiceClient.HandlerFinished(ctx, &ipc.HandlerFinishedRequest{ 110 | EgressId: h.conf.Info.EgressId, 111 | Metrics: m, 112 | Info: res, 113 | }) 114 | } 115 | 116 | func (h *Handler) Kill() { 117 | <-h.initialized.Watch() 118 | if h.controller == nil { 119 | return 120 | } 121 | h.controller.SendEOS(context.Background(), livekit.EndReasonKilled) 122 | } 123 | -------------------------------------------------------------------------------- /pkg/handler/handler_ipc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package handler 16 | 17 | import ( 18 | "context" 19 | "strings" 20 | 21 | "github.com/prometheus/client_golang/prometheus" 22 | dto "github.com/prometheus/client_model/go" 23 | "github.com/prometheus/common/expfmt" 24 | 25 | "github.com/livekit/egress/pkg/ipc" 26 | "github.com/livekit/protocol/logger" 27 | "github.com/livekit/protocol/pprof" 28 | "github.com/livekit/protocol/tracer" 29 | ) 30 | 31 | func (h *Handler) GetPipelineDot(ctx context.Context, _ *ipc.GstPipelineDebugDotRequest) (*ipc.GstPipelineDebugDotResponse, error) { 32 | ctx, span := tracer.Start(ctx, "Handler.GetPipelineDot") 33 | defer span.End() 34 | 35 | <-h.initialized.Watch() 36 | 37 | r, err := h.controller.GetGstPipelineDebugDot() 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | return &ipc.GstPipelineDebugDotResponse{ 43 | DotFile: r, 44 | }, nil 45 | } 46 | 47 | func (h *Handler) GetPProf(ctx context.Context, req *ipc.PProfRequest) (*ipc.PProfResponse, error) { 48 | ctx, span := tracer.Start(ctx, "Handler.GetPProf") 49 | defer span.End() 50 | 51 | <-h.initialized.Watch() 52 | 53 | b, err := pprof.GetProfileData(ctx, req.ProfileName, int(req.Timeout), int(req.Debug)) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return &ipc.PProfResponse{ 59 | PprofFile: b, 60 | }, nil 61 | } 62 | 63 | // GetMetrics implement the handler-side gathering of metrics to return over IPC 64 | func (h *Handler) GetMetrics(ctx context.Context, _ *ipc.MetricsRequest) (*ipc.MetricsResponse, error) { 65 | ctx, span := tracer.Start(ctx, "Handler.GetMetrics") 66 | defer span.End() 67 | 68 | metricsAsString, err := h.GenerateMetrics(ctx) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | return &ipc.MetricsResponse{ 74 | Metrics: metricsAsString, 75 | }, nil 76 | } 77 | 78 | func (h *Handler) GenerateMetrics(_ context.Context) (string, error) { 79 | metrics, err := prometheus.DefaultGatherer.Gather() 80 | if err != nil { 81 | return "", err 82 | } 83 | 84 | metricsAsString, err := renderMetrics(metrics) 85 | if err != nil { 86 | return "", err 87 | } 88 | 89 | return metricsAsString, nil 90 | } 91 | 92 | func renderMetrics(metrics []*dto.MetricFamily) (string, error) { 93 | // Create a StringWriter to render the metrics into text format 94 | writer := &strings.Builder{} 95 | totalCnt := 0 96 | for _, metric := range metrics { 97 | // Write each metric family to text 98 | cnt, err := expfmt.MetricFamilyToText(writer, metric) 99 | if err != nil { 100 | logger.Errorw("error writing metric family", err) 101 | return "", err 102 | } 103 | totalCnt += cnt 104 | } 105 | 106 | // Get the rendered metrics as a string from the StringWriter 107 | return writer.String(), nil 108 | } 109 | -------------------------------------------------------------------------------- /pkg/handler/handler_rpc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package handler 16 | 17 | import ( 18 | "context" 19 | 20 | "github.com/livekit/egress/pkg/errors" 21 | "github.com/livekit/protocol/livekit" 22 | "github.com/livekit/protocol/tracer" 23 | ) 24 | 25 | func (h *Handler) UpdateStream(ctx context.Context, req *livekit.UpdateStreamRequest) (*livekit.EgressInfo, error) { 26 | ctx, span := tracer.Start(ctx, "Handler.UpdateStream") 27 | defer span.End() 28 | 29 | <-h.initialized.Watch() 30 | if h.controller == nil { 31 | return nil, errors.ErrEgressNotFound 32 | } 33 | 34 | err := h.controller.UpdateStream(ctx, req) 35 | if err != nil { 36 | return nil, err 37 | } 38 | return h.controller.Info, nil 39 | } 40 | 41 | func (h *Handler) StopEgress(ctx context.Context, _ *livekit.StopEgressRequest) (*livekit.EgressInfo, error) { 42 | ctx, span := tracer.Start(ctx, "Handler.StopEgress") 43 | defer span.End() 44 | 45 | <-h.initialized.Watch() 46 | if h.controller == nil { 47 | return nil, errors.ErrEgressNotFound 48 | } 49 | 50 | h.controller.SendEOS(ctx, livekit.EndReasonAPI) 51 | return h.controller.Info, nil 52 | } 53 | -------------------------------------------------------------------------------- /pkg/ipc/conn.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package ipc 16 | 17 | import ( 18 | "context" 19 | "net" 20 | "path" 21 | 22 | "google.golang.org/grpc" 23 | "google.golang.org/grpc/credentials/insecure" 24 | 25 | "github.com/livekit/protocol/logger" 26 | ) 27 | 28 | const ( 29 | network = "unix" 30 | handlerAddress = "handler_ipc.sock" 31 | serviceAddress = "service_ipc.sock" 32 | ) 33 | 34 | func StartServiceListener(ipcServer *grpc.Server, serviceTmpDir string) error { 35 | listener, err := net.Listen(network, path.Join(serviceTmpDir, serviceAddress)) 36 | if err != nil { 37 | return err 38 | } 39 | 40 | go func() { 41 | if err = ipcServer.Serve(listener); err != nil { 42 | logger.Errorw("failed to start grpc handler", err) 43 | } 44 | }() 45 | 46 | return nil 47 | } 48 | 49 | func NewHandlerClient(handlerTmpDir string) (EgressHandlerClient, error) { 50 | socketAddr := path.Join(handlerTmpDir, handlerAddress) 51 | conn, err := grpc.Dial(socketAddr, 52 | grpc.WithTransportCredentials(insecure.NewCredentials()), 53 | grpc.WithContextDialer(func(_ context.Context, addr string) (net.Conn, error) { 54 | return net.Dial(network, addr) 55 | }), 56 | ) 57 | if err != nil { 58 | logger.Errorw("could not dial grpc handler", err) 59 | return nil, err 60 | } 61 | 62 | return NewEgressHandlerClient(conn), nil 63 | } 64 | 65 | func StartHandlerListener(ipcServer *grpc.Server, handlerTmpDir string) error { 66 | listener, err := net.Listen(network, path.Join(handlerTmpDir, handlerAddress)) 67 | if err != nil { 68 | return err 69 | } 70 | 71 | go func() { 72 | if err = ipcServer.Serve(listener); err != nil { 73 | logger.Errorw("failed to start grpc handler", err) 74 | } 75 | }() 76 | 77 | return nil 78 | } 79 | 80 | func NewServiceClient(serviceTmpDir string) (EgressServiceClient, error) { 81 | socketAddr := path.Join(serviceTmpDir, serviceAddress) 82 | conn, err := grpc.Dial(socketAddr, 83 | grpc.WithTransportCredentials(insecure.NewCredentials()), 84 | grpc.WithContextDialer(func(_ context.Context, addr string) (net.Conn, error) { 85 | return net.Dial(network, addr) 86 | }), 87 | ) 88 | if err != nil { 89 | logger.Errorw("could not dial grpc handler", err) 90 | return nil, err 91 | } 92 | 93 | return NewEgressServiceClient(conn), nil 94 | } 95 | -------------------------------------------------------------------------------- /pkg/ipc/ipc.proto: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | syntax = "proto3"; 16 | 17 | package ipc; 18 | option go_package = "github.com/livekit/egress/pkg/ipc"; 19 | 20 | import "google/protobuf/empty.proto"; 21 | import "livekit_egress.proto"; 22 | 23 | service EgressService { 24 | rpc HandlerReady(HandlerReadyRequest) returns (google.protobuf.Empty) {}; 25 | rpc HandlerUpdate(livekit.EgressInfo) returns (google.protobuf.Empty) {}; 26 | rpc HandlerFinished(HandlerFinishedRequest) returns (google.protobuf.Empty) {}; 27 | } 28 | 29 | message HandlerReadyRequest { 30 | string egress_id = 1; 31 | } 32 | 33 | message HandlerFinishedRequest { 34 | string egress_id = 1; 35 | string metrics = 2; 36 | livekit.EgressInfo info = 3; 37 | } 38 | 39 | service EgressHandler { 40 | rpc GetPipelineDot(GstPipelineDebugDotRequest) returns (GstPipelineDebugDotResponse) {}; 41 | rpc GetPProf(PProfRequest) returns (PProfResponse) {}; 42 | rpc GetMetrics(MetricsRequest) returns (MetricsResponse) {}; 43 | } 44 | 45 | message GstPipelineDebugDotRequest {} 46 | 47 | message GstPipelineDebugDotResponse { 48 | string dot_file = 1; 49 | } 50 | 51 | message PProfRequest { 52 | string profile_name = 1; 53 | int32 timeout = 2; 54 | int32 debug = 3; 55 | } 56 | 57 | message PProfResponse { 58 | bytes pprof_file = 1; 59 | } 60 | 61 | message MetricsRequest {} 62 | 63 | message MetricsResponse { 64 | string metrics = 1; 65 | } 66 | -------------------------------------------------------------------------------- /pkg/logging/csv.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package logging 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | "path" 21 | "reflect" 22 | "strings" 23 | "time" 24 | ) 25 | 26 | type TrackStats struct { 27 | Timestamp string 28 | PacketsReceived uint64 29 | PaddingReceived uint64 30 | LastReceived string 31 | PacketsDropped uint64 32 | PacketsPushed uint64 33 | SamplesPushed uint64 34 | LastPushed string 35 | Drift time.Duration 36 | } 37 | 38 | type StreamStats struct { 39 | Timestamp string 40 | Keyframes uint64 41 | OutBytesTotal uint64 42 | OutBytesAcked uint64 43 | InBytesTotal uint64 44 | InBytesAcked uint64 45 | } 46 | 47 | // CSVLogger is used for logging data in CSV format. It does not validate columns or data 48 | type CSVLogger[T any] struct { 49 | f *os.File 50 | } 51 | 52 | func NewCSVLogger[T any](filename string) (*CSVLogger[T], error) { 53 | if !strings.HasSuffix(filename, ".csv") { 54 | filename = filename + ".csv" 55 | } 56 | filename = path.Join(os.TempDir(), filename) 57 | f, err := os.Create(filename) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | columns := make([]string, 0) 63 | t := reflect.TypeFor[T]() 64 | for i := range t.NumField() { 65 | columns = append(columns, t.Field(i).Name) 66 | } 67 | _, _ = f.WriteString(fmt.Sprintf("%s\n", strings.Join(columns, ","))) 68 | 69 | return &CSVLogger[T]{ 70 | f: f, 71 | }, nil 72 | } 73 | 74 | func (l *CSVLogger[T]) Write(value *T) { 75 | v := reflect.ValueOf(value).Elem() 76 | t := v.Type() 77 | 78 | row := make([]string, t.NumField()) 79 | for i := range t.NumField() { 80 | row[i] = fmt.Sprintf("%v", v.Field(i).Interface()) 81 | } 82 | 83 | _, _ = l.f.WriteString(strings.Join(row, ",") + "\n") 84 | } 85 | 86 | func (l *CSVLogger[T]) Close() { 87 | _ = l.f.Close() 88 | } 89 | -------------------------------------------------------------------------------- /pkg/logging/handler.go: -------------------------------------------------------------------------------- 1 | package logging 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/livekit/protocol/logger" 8 | "github.com/livekit/protocol/logger/medialogutils" 9 | ) 10 | 11 | var sdkPrefixes = map[string]bool{ 12 | "turnc": true, // turnc ERROR 13 | "ice E": true, // ice ERROR 14 | "pc ER": true, // pc ERROR 15 | "twcc_": true, // twcc_sender_interceptor ERROR 16 | "SDK 2": true, // SDK 2025 17 | } 18 | 19 | var gstSuffixes = map[string]bool{ 20 | "before 'caps'": true, 21 | "f type 'gint'": true, 22 | } 23 | 24 | func NewHandlerLogger(handlerID, egressID string) *medialogutils.CmdLogger { 25 | l := logger.GetLogger().WithValues("handlerID", handlerID, "egressID", egressID) 26 | return medialogutils.NewCmdLogger(func(s string) { 27 | lines := strings.Split(s, "\n") 28 | for i, line := range lines { 29 | switch { 30 | case strings.HasSuffix(line, "}"): 31 | fmt.Println(line) 32 | case len(line) == 0: 33 | continue 34 | case len(line) > 5 && sdkPrefixes[line[:5]]: 35 | l.Infow(line) 36 | case strings.HasPrefix(line, "{\"level\":"): 37 | // should have ended with "}", probably got split 38 | if i < len(lines)-1 && strings.HasSuffix(lines[i+1], "}") { 39 | line = line + lines[i+1] 40 | i++ 41 | } 42 | fmt.Println(line) 43 | case strings.HasPrefix(line, "(egress:"): 44 | if len(line) > 13 && !gstSuffixes[line[len(line)-13:]] && i < len(lines)-1 { 45 | next := lines[i+1] 46 | if len(next) > 13 && gstSuffixes[next[:13]] { 47 | // line got split 48 | line = line + next 49 | i++ 50 | } 51 | } 52 | logger.Warnw(line, nil) 53 | case strings.HasPrefix(line, "0:00:"): 54 | if !strings.HasSuffix(line, "is not mapped") { 55 | // line got split 56 | if i < len(lines)-1 && strings.HasSuffix(lines[i+1], "is not mapped") { 57 | i++ 58 | } 59 | } 60 | continue 61 | default: 62 | l.Errorw(line, nil) 63 | } 64 | } 65 | }) 66 | } 67 | -------------------------------------------------------------------------------- /pkg/logging/s3.go: -------------------------------------------------------------------------------- 1 | // Copyright 2025 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package logging 16 | 17 | import ( 18 | "fmt" 19 | "strings" 20 | "sync" 21 | 22 | "github.com/aws/smithy-go/logging" 23 | 24 | "github.com/livekit/protocol/logger" 25 | ) 26 | 27 | // S3Logger only logs aws messages on upload failure 28 | type S3Logger struct { 29 | mu sync.Mutex 30 | msgs []string 31 | idx int 32 | } 33 | 34 | func NewS3Logger() *S3Logger { 35 | return &S3Logger{ 36 | msgs: make([]string, 10), 37 | } 38 | } 39 | 40 | func (l *S3Logger) Logf(classification logging.Classification, format string, v ...interface{}) { 41 | format = "aws %s: " + format 42 | v = append([]interface{}{strings.ToLower(string(classification))}, v...) 43 | 44 | l.mu.Lock() 45 | l.msgs[l.idx%len(l.msgs)] = fmt.Sprintf(format, v...) 46 | l.idx++ 47 | l.mu.Unlock() 48 | } 49 | 50 | func (l *S3Logger) WriteLogs() { 51 | l.mu.Lock() 52 | size := len(l.msgs) 53 | for range size { 54 | if msg := l.msgs[l.idx%size]; msg != "" { 55 | logger.Debugw(msg) 56 | } 57 | l.idx++ 58 | } 59 | l.mu.Unlock() 60 | } 61 | -------------------------------------------------------------------------------- /pkg/pipeline/builder/file.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package builder 16 | 17 | import ( 18 | "github.com/go-gst/go-gst/gst" 19 | 20 | "github.com/livekit/egress/pkg/config" 21 | "github.com/livekit/egress/pkg/errors" 22 | "github.com/livekit/egress/pkg/gstreamer" 23 | "github.com/livekit/egress/pkg/types" 24 | ) 25 | 26 | func BuildFileBin(pipeline *gstreamer.Pipeline, p *config.PipelineConfig) (*gstreamer.Bin, error) { 27 | b := pipeline.NewBin("file") 28 | o := p.GetFileConfig() 29 | 30 | var mux *gst.Element 31 | var err error 32 | switch o.OutputType { 33 | case types.OutputTypeOGG: 34 | mux, err = gst.NewElement("oggmux") 35 | case types.OutputTypeIVF: 36 | mux, err = gst.NewElement("avmux_ivf") 37 | case types.OutputTypeMP4: 38 | mux, err = gst.NewElement("mp4mux") 39 | case types.OutputTypeWebM: 40 | mux, err = gst.NewElement("webmmux") 41 | default: 42 | return nil, errors.ErrInvalidInput("output type") 43 | } 44 | if err != nil { 45 | return nil, errors.ErrGstPipelineError(err) 46 | } 47 | 48 | sink, err := gst.NewElement("filesink") 49 | if err != nil { 50 | return nil, errors.ErrGstPipelineError(err) 51 | } 52 | if err = sink.SetProperty("location", o.LocalFilepath); err != nil { 53 | return nil, errors.ErrGstPipelineError(err) 54 | } 55 | if err = sink.SetProperty("sync", false); err != nil { 56 | return nil, errors.ErrGstPipelineError(err) 57 | } 58 | if err = b.AddElements(mux, sink); err != nil { 59 | return nil, err 60 | } 61 | 62 | b.SetGetSrcPad(func(name string) *gst.Pad { 63 | var padName = name + "_%u" 64 | 65 | return mux.GetRequestPad(padName) 66 | }) 67 | 68 | return b, nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/pipeline/builder/image.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package builder 16 | 17 | import ( 18 | "fmt" 19 | "path" 20 | "time" 21 | 22 | "github.com/go-gst/go-gst/gst" 23 | 24 | "github.com/livekit/egress/pkg/config" 25 | "github.com/livekit/egress/pkg/errors" 26 | "github.com/livekit/egress/pkg/gstreamer" 27 | "github.com/livekit/egress/pkg/types" 28 | ) 29 | 30 | const ( 31 | imageQueueLatency = uint64(200 * time.Millisecond) 32 | ) 33 | 34 | func BuildImageBin(c *config.ImageConfig, pipeline *gstreamer.Pipeline, p *config.PipelineConfig) (*gstreamer.Bin, error) { 35 | b := pipeline.NewBin(fmt.Sprintf("image_%s", c.Id)) 36 | 37 | var err error 38 | var fakeAudio *gst.Element 39 | if p.AudioEnabled { 40 | fakeAudio, err = gst.NewElement("fakesink") 41 | if err != nil { 42 | return nil, err 43 | } 44 | } 45 | 46 | queue, err := gstreamer.BuildQueue(fmt.Sprintf("image_queue_%s", c.Id), imageQueueLatency, true) 47 | if err != nil { 48 | return nil, err 49 | } 50 | if err := b.AddElements(queue); err != nil { 51 | return nil, errors.ErrGstPipelineError(err) 52 | } 53 | 54 | b.SetGetSrcPad(func(name string) *gst.Pad { 55 | if name == "audio" { 56 | return fakeAudio.GetStaticPad("sink") 57 | } else { 58 | return queue.GetStaticPad("sink") 59 | } 60 | }) 61 | b.SetShouldLink(func(srcBin string) bool { 62 | return srcBin != "audio" 63 | }) 64 | 65 | videoRate, err := gst.NewElement("videorate") 66 | if err != nil { 67 | return nil, errors.ErrGstPipelineError(err) 68 | } 69 | if err = videoRate.SetProperty("skip-to-first", true); err != nil { 70 | return nil, err 71 | } 72 | if err := b.AddElements(videoRate); err != nil { 73 | return nil, errors.ErrGstPipelineError(err) 74 | } 75 | 76 | videoScale, err := gst.NewElement("videoscale") 77 | if err != nil { 78 | return nil, errors.ErrGstPipelineError(err) 79 | } 80 | if err := b.AddElements(videoScale); err != nil { 81 | return nil, errors.ErrGstPipelineError(err) 82 | } 83 | 84 | caps, err := gst.NewElement("capsfilter") 85 | if err != nil { 86 | return nil, errors.ErrGstPipelineError(err) 87 | } 88 | 89 | capsString := fmt.Sprintf( 90 | "video/x-raw,framerate=1/%d,format=I420,colorimetry=bt709,chroma-site=mpeg2,pixel-aspect-ratio=1/1", 91 | c.CaptureInterval) 92 | 93 | if c.Width > 0 && c.Height > 0 { 94 | capsString = fmt.Sprintf("%s,width=%d,height=%d,", capsString, c.Width, c.Height) 95 | } 96 | 97 | err = caps.SetProperty("caps", gst.NewCapsFromString(capsString)) 98 | if err != nil { 99 | return nil, err 100 | } 101 | if err := b.AddElements(caps); err != nil { 102 | return nil, errors.ErrGstPipelineError(err) 103 | } 104 | 105 | switch c.ImageOutCodec { 106 | case types.MimeTypeJPEG: 107 | enc, err := gst.NewElement("jpegenc") 108 | if err != nil { 109 | return nil, errors.ErrGstPipelineError(err) 110 | } 111 | if err := b.AddElements(enc); err != nil { 112 | return nil, errors.ErrGstPipelineError(err) 113 | } 114 | default: 115 | return nil, errors.ErrNoCompatibleCodec 116 | } 117 | 118 | sink, err := gst.NewElementWithName("multifilesink", fmt.Sprintf("multifilesink_%s", c.Id)) 119 | if err != nil { 120 | return nil, err 121 | } 122 | err = sink.SetProperty("post-messages", true) 123 | if err != nil { 124 | return nil, err 125 | } 126 | 127 | // File will be renamed if the TS prefix is configured 128 | location := fmt.Sprintf("%s_%%05d%s", path.Join(c.LocalDir, c.ImagePrefix), types.FileExtensionForOutputType[c.OutputType]) 129 | 130 | err = sink.SetProperty("location", location) 131 | if err != nil { 132 | return nil, err 133 | } 134 | if err = b.AddElements(sink); err != nil { 135 | return nil, errors.ErrGstPipelineError(err) 136 | } 137 | 138 | return b, nil 139 | } 140 | -------------------------------------------------------------------------------- /pkg/pipeline/builder/segment.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package builder 16 | 17 | import ( 18 | "fmt" 19 | "path" 20 | "time" 21 | 22 | "github.com/go-gst/go-gst/gst" 23 | 24 | "github.com/livekit/egress/pkg/config" 25 | "github.com/livekit/egress/pkg/errors" 26 | "github.com/livekit/egress/pkg/gstreamer" 27 | "github.com/livekit/protocol/livekit" 28 | "github.com/livekit/protocol/logger" 29 | ) 30 | 31 | type FirstSampleMetadata struct { 32 | StartDate int64 // Real time date of the first media sample 33 | } 34 | 35 | func BuildSegmentBin(pipeline *gstreamer.Pipeline, p *config.PipelineConfig) (*gstreamer.Bin, error) { 36 | b := pipeline.NewBin("segment") 37 | o := p.GetSegmentConfig() 38 | 39 | var h264parse *gst.Element 40 | var err error 41 | if p.VideoEnabled { 42 | h264parse, err = gst.NewElement("h264parse") 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | if err = b.AddElements(h264parse); err != nil { 48 | return nil, errors.ErrGstPipelineError(err) 49 | } 50 | } 51 | 52 | sink, err := gst.NewElement("splitmuxsink") 53 | if err != nil { 54 | return nil, errors.ErrGstPipelineError(err) 55 | } 56 | if err = sink.SetProperty("max-size-time", uint64(time.Duration(o.SegmentDuration)*time.Second)); err != nil { 57 | return nil, errors.ErrGstPipelineError(err) 58 | } 59 | if err = sink.SetProperty("send-keyframe-requests", true); err != nil { 60 | return nil, errors.ErrGstPipelineError(err) 61 | } 62 | if err = sink.SetProperty("muxer-factory", "mpegtsmux"); err != nil { 63 | return nil, errors.ErrGstPipelineError(err) 64 | } 65 | 66 | var startDate time.Time 67 | _, err = sink.Connect("format-location-full", func(self *gst.Element, fragmentId uint, firstSample *gst.Sample) string { 68 | var pts time.Duration 69 | if firstSample != nil && firstSample.GetBuffer() != nil { 70 | pts = *firstSample.GetBuffer().PresentationTimestamp().AsDuration() 71 | } else { 72 | logger.Infow("nil sample passed into 'format-location-full' event handler, assuming 0 pts") 73 | } 74 | 75 | if startDate.IsZero() { 76 | now := time.Now() 77 | 78 | startDate = now.Add(-pts) 79 | 80 | mdata := FirstSampleMetadata{ 81 | StartDate: now.UnixNano(), 82 | } 83 | str := gst.MarshalStructure(mdata) 84 | msg := gst.NewElementMessage(sink, str) 85 | sink.GetBus().Post(msg) 86 | } 87 | 88 | var segmentName string 89 | switch o.SegmentSuffix { 90 | case livekit.SegmentedFileSuffix_TIMESTAMP: 91 | ts := startDate.Add(pts) 92 | segmentName = fmt.Sprintf("%s_%s%03d.ts", o.SegmentPrefix, ts.Format("20060102150405"), ts.UnixMilli()%1000) 93 | default: 94 | segmentName = fmt.Sprintf("%s_%05d.ts", o.SegmentPrefix, fragmentId) 95 | } 96 | return path.Join(o.LocalDir, segmentName) 97 | }) 98 | if err != nil { 99 | return nil, errors.ErrGstPipelineError(err) 100 | } 101 | 102 | if err = b.AddElements(sink); err != nil { 103 | return nil, errors.ErrGstPipelineError(err) 104 | } 105 | 106 | b.SetGetSrcPad(func(name string) *gst.Pad { 107 | if name == "audio" { 108 | return sink.GetRequestPad("audio_%u") 109 | } else if h264parse != nil { 110 | return h264parse.GetStaticPad("sink") 111 | } else { 112 | // Should never happen 113 | return nil 114 | } 115 | }) 116 | 117 | return b, nil 118 | } 119 | -------------------------------------------------------------------------------- /pkg/pipeline/builder/websocket.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package builder 16 | 17 | import ( 18 | "github.com/go-gst/go-gst/gst" 19 | "github.com/go-gst/go-gst/gst/app" 20 | 21 | "github.com/livekit/egress/pkg/errors" 22 | "github.com/livekit/egress/pkg/gstreamer" 23 | ) 24 | 25 | func BuildWebsocketBin(pipeline *gstreamer.Pipeline, appSinkCallbacks *app.SinkCallbacks) (*gstreamer.Bin, error) { 26 | b := pipeline.NewBin("websocket") 27 | 28 | appSink, err := app.NewAppSink() 29 | if err != nil { 30 | return nil, errors.ErrGstPipelineError(err) 31 | } 32 | appSink.SetCallbacks(appSinkCallbacks) 33 | 34 | if err = b.AddElement(appSink.Element); err != nil { 35 | return nil, err 36 | } 37 | 38 | b.SetGetSrcPad(func(name string) *gst.Pad { 39 | return appSink.GetStaticPad("sink") 40 | }) 41 | 42 | return b, nil 43 | } 44 | -------------------------------------------------------------------------------- /pkg/pipeline/debug.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package pipeline 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "os" 21 | "path" 22 | "strings" 23 | "time" 24 | 25 | "github.com/go-gst/go-gst/gst" 26 | "google.golang.org/grpc/codes" 27 | "google.golang.org/grpc/status" 28 | 29 | "github.com/livekit/egress/pkg/pipeline/sink/uploader" 30 | "github.com/livekit/egress/pkg/types" 31 | "github.com/livekit/protocol/logger" 32 | "github.com/livekit/protocol/pprof" 33 | ) 34 | 35 | func (c *Controller) GetGstPipelineDebugDot() (string, error) { 36 | dot := make(chan string, 1) 37 | go func() { 38 | dot <- c.p.DebugBinToDotData(gst.DebugGraphShowAll) 39 | }() 40 | 41 | select { 42 | case d := <-dot: 43 | return d, nil 44 | case <-time.After(3 * time.Second): 45 | return "", status.New(codes.DeadlineExceeded, "timed out requesting pipeline debug info").Err() 46 | } 47 | } 48 | 49 | func (c *Controller) generateDotFile() { 50 | dot, err := c.GetGstPipelineDebugDot() 51 | if err != nil { 52 | return 53 | } 54 | 55 | f, err := os.Create(path.Join(c.TmpDir, fmt.Sprintf("%s.dot", c.Info.EgressId))) 56 | if err != nil { 57 | return 58 | } 59 | defer f.Close() 60 | 61 | _, _ = f.WriteString(dot) 62 | } 63 | 64 | func (c *Controller) generatePProf() { 65 | b, err := pprof.GetProfileData(context.Background(), "goroutine", 0, 0) 66 | if err != nil { 67 | logger.Errorw("failed to get profile data", err) 68 | return 69 | } 70 | 71 | f, err := os.Create(path.Join(c.TmpDir, fmt.Sprintf("%s.prof", c.Info.EgressId))) 72 | if err != nil { 73 | return 74 | } 75 | defer f.Close() 76 | 77 | _, _ = f.Write(b) 78 | } 79 | 80 | var debugFileExtensions = map[string]struct{}{ 81 | "csv": {}, 82 | "dot": {}, 83 | "prof": {}, 84 | "log": {}, 85 | } 86 | 87 | func (c *Controller) uploadDebugFiles() { 88 | files, err := os.ReadDir(c.TmpDir) 89 | if err != nil { 90 | logger.Errorw("failed to read tmp dir", err) 91 | return 92 | } 93 | 94 | var u *uploader.Uploader 95 | 96 | for _, f := range files { 97 | s := strings.Split(f.Name(), ".") 98 | if _, ok := debugFileExtensions[s[len(s)-1]]; !ok { 99 | continue 100 | } 101 | 102 | if u == nil { 103 | u, err = uploader.New(&c.Debug.StorageConfig, nil, c.monitor, nil) 104 | if err != nil { 105 | logger.Errorw("failed to create uploader", err) 106 | return 107 | } 108 | } 109 | 110 | local := path.Join(c.TmpDir, f.Name()) 111 | storage := path.Join(c.Info.EgressId, f.Name()) 112 | _, _, err = u.Upload(local, storage, types.OutputTypeBlob, false) 113 | if err != nil { 114 | logger.Errorw("failed to upload debug file", err) 115 | return 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /pkg/pipeline/sink/file.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package sink 16 | 17 | import ( 18 | "path" 19 | 20 | "github.com/livekit/egress/pkg/config" 21 | "github.com/livekit/egress/pkg/gstreamer" 22 | "github.com/livekit/egress/pkg/pipeline/builder" 23 | "github.com/livekit/egress/pkg/pipeline/sink/uploader" 24 | "github.com/livekit/egress/pkg/stats" 25 | "github.com/livekit/egress/pkg/types" 26 | ) 27 | 28 | type FileSink struct { 29 | *base 30 | *config.FileConfig 31 | *uploader.Uploader 32 | 33 | conf *config.PipelineConfig 34 | } 35 | 36 | func newFileSink( 37 | p *gstreamer.Pipeline, 38 | conf *config.PipelineConfig, 39 | o *config.FileConfig, 40 | monitor *stats.HandlerMonitor, 41 | ) (*FileSink, error) { 42 | u, err := uploader.New(o.StorageConfig, conf.BackupConfig, monitor, conf.Info) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | fileBin, err := builder.BuildFileBin(p, conf) 48 | if err != nil { 49 | return nil, err 50 | } 51 | if err = p.AddSinkBin(fileBin); err != nil { 52 | return nil, err 53 | } 54 | 55 | return &FileSink{ 56 | base: &base{ 57 | bin: fileBin, 58 | }, 59 | FileConfig: o, 60 | Uploader: u, 61 | conf: conf, 62 | }, nil 63 | } 64 | 65 | func (s *FileSink) Start() error { 66 | return nil 67 | } 68 | 69 | func (s *FileSink) UploadManifest(filepath string) (string, bool, error) { 70 | if s.DisableManifest && !s.conf.Info.BackupStorageUsed { 71 | return "", false, nil 72 | } 73 | 74 | storagePath := path.Join(path.Dir(s.StorageFilepath), path.Base(filepath)) 75 | location, _, err := s.Upload(filepath, storagePath, types.OutputTypeJSON, false) 76 | if err != nil { 77 | return "", false, err 78 | } 79 | 80 | return location, true, nil 81 | } 82 | 83 | func (s *FileSink) Close() error { 84 | location, size, err := s.Upload(s.LocalFilepath, s.StorageFilepath, s.OutputType, false) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | s.FileInfo.Location = location 90 | s.FileInfo.Size = size 91 | 92 | if s.conf.Manifest != nil { 93 | s.conf.Manifest.AddFile(s.StorageFilepath, location) 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /pkg/pipeline/sink/m3u8/writer_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package m3u8 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | "testing" 21 | "time" 22 | 23 | "github.com/stretchr/testify/require" 24 | ) 25 | 26 | func TestEventPlaylistWriter(t *testing.T) { 27 | playlistName := "playlist.m3u8" 28 | 29 | w, err := NewEventPlaylistWriter(playlistName, 6) 30 | require.NoError(t, err) 31 | 32 | t.Cleanup(func() { _ = os.Remove(playlistName) }) 33 | 34 | now := time.Unix(0, 1683154504814142000) 35 | duration := 5.994 36 | 37 | for i := 0; i < 3; i++ { 38 | require.NoError(t, w.Append(now, duration, fmt.Sprintf("playlist_0000%d.ts", i))) 39 | now = now.Add(time.Millisecond * 5994) 40 | } 41 | 42 | require.NoError(t, w.Close()) 43 | 44 | b, err := os.ReadFile(playlistName) 45 | require.NoError(t, err) 46 | 47 | expected := "#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-PLAYLIST-TYPE:EVENT\n#EXT-X-ALLOW-CACHE:NO\n#EXT-X-TARGETDURATION:6\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PROGRAM-DATE-TIME:2023-05-03T22:55:04.814Z\n#EXTINF:5.994,\nplaylist_00000.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-05-03T22:55:10.808Z\n#EXTINF:5.994,\nplaylist_00001.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-05-03T22:55:16.802Z\n#EXTINF:5.994,\nplaylist_00002.ts\n#EXT-X-ENDLIST\n" 48 | require.Equal(t, expected, string(b)) 49 | } 50 | 51 | func TestLivePlaylistWriter(t *testing.T) { 52 | playlistName := "playlist.m3u8" 53 | 54 | w, err := NewLivePlaylistWriter(playlistName, 6, 3) 55 | require.NoError(t, err) 56 | 57 | t.Cleanup(func() { _ = os.Remove(playlistName) }) 58 | 59 | now := time.Unix(0, 1683154504814142000) 60 | duration := 5.994 61 | 62 | for i := 0; i < 2; i++ { 63 | require.NoError(t, w.Append(now, duration, fmt.Sprintf("playlist_0000%d.ts", i))) 64 | now = now.Add(time.Millisecond * 5994) 65 | } 66 | 67 | b, err := os.ReadFile(playlistName) 68 | require.NoError(t, err) 69 | 70 | expected := "#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-ALLOW-CACHE:NO\n#EXT-X-TARGETDURATION:6\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-PROGRAM-DATE-TIME:2023-05-03T22:55:04.814Z\n#EXTINF:5.994,\nplaylist_00000.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-05-03T22:55:10.808Z\n#EXTINF:5.994,\nplaylist_00001.ts\n" 71 | require.Equal(t, expected, string(b)) 72 | 73 | for i := 2; i < 4; i++ { 74 | require.NoError(t, w.Append(now, duration, fmt.Sprintf("playlist_0000%d.ts", i))) 75 | now = now.Add(time.Millisecond * 5994) 76 | } 77 | 78 | require.NoError(t, w.Close()) 79 | 80 | b, err = os.ReadFile(playlistName) 81 | require.NoError(t, err) 82 | 83 | expected = "#EXTM3U\n#EXT-X-VERSION:4\n#EXT-X-ALLOW-CACHE:NO\n#EXT-X-TARGETDURATION:6\n#EXT-X-MEDIA-SEQUENCE:1\n#EXT-X-PROGRAM-DATE-TIME:2023-05-03T22:55:04.814Z\n#EXTINF:5.994,\nplaylist_00001.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-05-03T22:55:16.802Z\n#EXTINF:5.994,\nplaylist_00002.ts\n#EXT-X-PROGRAM-DATE-TIME:2023-05-03T22:55:22.796Z\n#EXTINF:5.994,\nplaylist_00003.ts\n#EXT-X-ENDLIST\n" 84 | require.Equal(t, expected, string(b)) 85 | } 86 | -------------------------------------------------------------------------------- /pkg/pipeline/sink/sink.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package sink 16 | 17 | import ( 18 | "go.uber.org/atomic" 19 | 20 | "github.com/livekit/egress/pkg/config" 21 | "github.com/livekit/egress/pkg/errors" 22 | "github.com/livekit/egress/pkg/gstreamer" 23 | "github.com/livekit/egress/pkg/stats" 24 | "github.com/livekit/egress/pkg/types" 25 | "github.com/livekit/protocol/logger" 26 | ) 27 | 28 | type Sink interface { 29 | Start() error 30 | AddEOSProbe() 31 | EOSReceived() bool 32 | Close() error 33 | UploadManifest(string) (string, bool, error) 34 | } 35 | 36 | type base struct { 37 | bin *gstreamer.Bin 38 | eosReceived atomic.Bool 39 | } 40 | 41 | func NewSink( 42 | p *gstreamer.Pipeline, 43 | conf *config.PipelineConfig, 44 | egressType types.EgressType, 45 | o config.OutputConfig, 46 | callbacks *gstreamer.Callbacks, 47 | monitor *stats.HandlerMonitor, 48 | ) (Sink, error) { 49 | 50 | switch egressType { 51 | case types.EgressTypeFile: 52 | return newFileSink(p, conf, o.(*config.FileConfig), monitor) 53 | 54 | case types.EgressTypeSegments: 55 | return newSegmentSink(p, conf, o.(*config.SegmentConfig), callbacks, monitor) 56 | 57 | case types.EgressTypeStream: 58 | return newStreamSink(p, conf, o.(*config.StreamConfig)) 59 | 60 | case types.EgressTypeWebsocket: 61 | return newWebsocketSink(p, o.(*config.StreamConfig), types.MimeTypeRawAudio, callbacks) 62 | 63 | case types.EgressTypeImages: 64 | return newImageSink(p, conf, o.(*config.ImageConfig), callbacks, monitor) 65 | 66 | default: 67 | return nil, errors.ErrInvalidInput("output type") 68 | } 69 | } 70 | 71 | func (s *base) AddEOSProbe() { 72 | if err := s.bin.AddOnEOSReceived(func() { 73 | s.eosReceived.Store(true) 74 | }); err != nil { 75 | logger.Errorw("failed to add EOS probe", err) 76 | } 77 | } 78 | 79 | func (s *base) EOSReceived() bool { 80 | return s.eosReceived.Load() 81 | } 82 | -------------------------------------------------------------------------------- /pkg/pipeline/sink/uploader/alioss.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package uploader 16 | 17 | import ( 18 | "fmt" 19 | "os" 20 | "path" 21 | 22 | "github.com/aliyun/aliyun-oss-go-sdk/oss" 23 | 24 | "github.com/livekit/egress/pkg/config" 25 | "github.com/livekit/egress/pkg/errors" 26 | "github.com/livekit/egress/pkg/types" 27 | ) 28 | 29 | type AliOSSUploader struct { 30 | conf *config.S3Config 31 | prefix string 32 | generatePresignedUrl bool 33 | } 34 | 35 | func newAliOSSUploader(c *config.StorageConfig) (uploader, error) { 36 | if c.GeneratePresignedUrl { 37 | return nil, errors.ErrUploadFailed("AliOSS", fmt.Errorf("presigned URLs not supported")) 38 | } 39 | 40 | conf := c.AliOSS 41 | return &AliOSSUploader{ 42 | conf: conf, 43 | prefix: c.Prefix, 44 | generatePresignedUrl: c.GeneratePresignedUrl, 45 | }, nil 46 | } 47 | 48 | func (u *AliOSSUploader) upload(localFilepath, storageFilepath string, _ types.OutputType) (string, int64, error) { 49 | storageFilepath = path.Join(u.prefix, storageFilepath) 50 | 51 | stat, err := os.Stat(localFilepath) 52 | if err != nil { 53 | return "", 0, errors.ErrUploadFailed("AliOSS", err) 54 | } 55 | 56 | client, err := oss.New(u.conf.Endpoint, u.conf.AccessKey, u.conf.Secret) 57 | if err != nil { 58 | return "", 0, errors.ErrUploadFailed("AliOSS", err) 59 | } 60 | 61 | bucket, err := client.Bucket(u.conf.Bucket) 62 | if err != nil { 63 | return "", 0, errors.ErrUploadFailed("AliOSS", err) 64 | } 65 | 66 | err = bucket.PutObjectFromFile(storageFilepath, localFilepath) 67 | if err != nil { 68 | return "", 0, errors.ErrUploadFailed("AliOSS", err) 69 | } 70 | 71 | return fmt.Sprintf("https://%s.%s/%s", u.conf.Bucket, u.conf.Endpoint, storageFilepath), stat.Size(), nil 72 | } 73 | -------------------------------------------------------------------------------- /pkg/pipeline/sink/uploader/azure.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package uploader 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "net/url" 21 | "os" 22 | "path" 23 | 24 | "github.com/Azure/azure-storage-blob-go/azblob" 25 | 26 | "github.com/livekit/egress/pkg/config" 27 | "github.com/livekit/egress/pkg/errors" 28 | "github.com/livekit/egress/pkg/types" 29 | ) 30 | 31 | type AzureUploader struct { 32 | conf *config.AzureConfig 33 | prefix string 34 | container string 35 | generatePresignedUrl bool 36 | } 37 | 38 | func newAzureUploader(c *config.StorageConfig) (uploader, error) { 39 | if c.GeneratePresignedUrl { 40 | return nil, errors.ErrUploadFailed("Azure", fmt.Errorf("presigned URLs not supported")) 41 | } 42 | 43 | conf := c.Azure 44 | return &AzureUploader{ 45 | conf: conf, 46 | prefix: c.Prefix, 47 | generatePresignedUrl: c.GeneratePresignedUrl, 48 | container: fmt.Sprintf("https://%s.blob.core.windows.net/%s", conf.AccountName, conf.ContainerName), 49 | }, nil 50 | } 51 | 52 | func (u *AzureUploader) upload(localFilepath, storageFilepath string, outputType types.OutputType) (string, int64, error) { 53 | storageFilepath = path.Join(u.prefix, storageFilepath) 54 | 55 | credential, err := azblob.NewSharedKeyCredential( 56 | u.conf.AccountName, 57 | u.conf.AccountKey, 58 | ) 59 | if err != nil { 60 | return "", 0, errors.ErrUploadFailed("Azure", err) 61 | } 62 | 63 | azUrl, err := url.Parse(u.container) 64 | if err != nil { 65 | return "", 0, errors.ErrUploadFailed("Azure", err) 66 | } 67 | 68 | pipeline := azblob.NewPipeline(credential, azblob.PipelineOptions{ 69 | Retry: azblob.RetryOptions{ 70 | Policy: azblob.RetryPolicyExponential, 71 | MaxTries: maxRetries, 72 | RetryDelay: minDelay, 73 | MaxRetryDelay: maxDelay, 74 | }, 75 | }) 76 | containerURL := azblob.NewContainerURL(*azUrl, pipeline) 77 | blobURL := containerURL.NewBlockBlobURL(storageFilepath) 78 | 79 | file, err := os.Open(localFilepath) 80 | if err != nil { 81 | return "", 0, errors.ErrUploadFailed("Azure", err) 82 | } 83 | defer func() { 84 | _ = file.Close() 85 | }() 86 | 87 | stat, err := file.Stat() 88 | if err != nil { 89 | return "", 0, errors.ErrUploadFailed("Azure", err) 90 | } 91 | 92 | // upload blocks in parallel for optimal performance 93 | // it calls PutBlock/PutBlockList for files larger than 256 MBs and PutBlob for smaller files 94 | _, err = azblob.UploadFileToBlockBlob(context.Background(), file, blobURL, azblob.UploadToBlockBlobOptions{ 95 | BlobHTTPHeaders: azblob.BlobHTTPHeaders{ContentType: string(outputType)}, 96 | BlockSize: 4 * 1024 * 1024, 97 | Parallelism: 16, 98 | }) 99 | if err != nil { 100 | return "", 0, errors.ErrUploadFailed("Azure", err) 101 | } 102 | 103 | return fmt.Sprintf("%s/%s", u.container, storageFilepath), stat.Size(), nil 104 | } 105 | -------------------------------------------------------------------------------- /pkg/pipeline/sink/uploader/gcp.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package uploader 16 | 17 | import ( 18 | "context" 19 | "encoding/base64" 20 | "fmt" 21 | "io" 22 | "net/http" 23 | "net/url" 24 | "os" 25 | "path" 26 | 27 | "cloud.google.com/go/storage" 28 | "github.com/googleapis/gax-go/v2" 29 | "golang.org/x/oauth2/google" 30 | "google.golang.org/api/option" 31 | 32 | "github.com/livekit/egress/pkg/config" 33 | "github.com/livekit/egress/pkg/errors" 34 | "github.com/livekit/egress/pkg/types" 35 | ) 36 | 37 | const storageScope = "https://www.googleapis.com/auth/devstorage.read_write" 38 | 39 | type GCPUploader struct { 40 | conf *config.GCPConfig 41 | prefix string 42 | generatePresignedUrl bool 43 | client *storage.Client 44 | } 45 | 46 | func newGCPUploader(c *config.StorageConfig) (uploader, error) { 47 | if c.GeneratePresignedUrl { 48 | return nil, errors.ErrUploadFailed("GCP", fmt.Errorf("presigned URLs not supported")) 49 | } 50 | 51 | conf := c.GCP 52 | u := &GCPUploader{ 53 | conf: conf, 54 | prefix: c.Prefix, 55 | generatePresignedUrl: c.GeneratePresignedUrl, 56 | } 57 | 58 | var opts []option.ClientOption 59 | if conf.CredentialsJSON != "" { 60 | jwtConfig, err := google.JWTConfigFromJSON([]byte(conf.CredentialsJSON), storageScope) 61 | if err != nil { 62 | return nil, errors.ErrUploadFailed("GCP", err) 63 | } 64 | opts = append(opts, option.WithTokenSource(jwtConfig.TokenSource(context.Background()))) 65 | } 66 | 67 | defaultTransport := http.DefaultTransport.(*http.Transport) 68 | transportClone := defaultTransport.Clone() 69 | 70 | if conf.ProxyConfig != nil { 71 | proxyUrl, err := url.Parse(conf.ProxyConfig.Url) 72 | if err != nil { 73 | return nil, err 74 | } 75 | defaultTransport.Proxy = http.ProxyURL(proxyUrl) 76 | if conf.ProxyConfig.Username != "" && conf.ProxyConfig.Password != "" { 77 | auth := fmt.Sprintf("%s:%s", conf.ProxyConfig.Username, conf.ProxyConfig.Password) 78 | basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte(auth)) 79 | defaultTransport.ProxyConnectHeader = http.Header{} 80 | defaultTransport.ProxyConnectHeader.Add("Proxy-Authorization", basicAuth) 81 | } 82 | } 83 | 84 | client, err := storage.NewClient(context.Background(), opts...) 85 | // restore default transport 86 | http.DefaultTransport = transportClone 87 | if err != nil { 88 | return nil, errors.ErrUploadFailed("GCP", err) 89 | } 90 | 91 | u.client = client 92 | return u, nil 93 | } 94 | 95 | func (u *GCPUploader) upload(localFilepath, storageFilepath string, _ types.OutputType) (string, int64, error) { 96 | storageFilepath = path.Join(u.prefix, storageFilepath) 97 | 98 | file, err := os.Open(localFilepath) 99 | if err != nil { 100 | return "", 0, errors.ErrUploadFailed("GCP", err) 101 | } 102 | defer func() { 103 | _ = file.Close() 104 | }() 105 | 106 | stat, err := file.Stat() 107 | if err != nil { 108 | return "", 0, errors.ErrUploadFailed("GCP", err) 109 | } 110 | 111 | wc := u.client.Bucket(u.conf.Bucket).Object(storageFilepath).Retryer( 112 | storage.WithBackoff(gax.Backoff{ 113 | Initial: minDelay, 114 | Max: maxDelay, 115 | Multiplier: 2, 116 | }), 117 | storage.WithMaxAttempts(maxRetries), 118 | storage.WithPolicy(storage.RetryAlways), 119 | ).NewWriter(context.Background()) 120 | wc.ChunkRetryDeadline = 0 121 | 122 | if _, err = io.Copy(wc, file); err != nil { 123 | return "", 0, errors.ErrUploadFailed("GCP", err) 124 | } 125 | 126 | if err = wc.Close(); err != nil { 127 | return "", 0, errors.ErrUploadFailed("GCP", err) 128 | } 129 | 130 | return fmt.Sprintf("https://%s.storage.googleapis.com/%s", u.conf.Bucket, storageFilepath), stat.Size(), nil 131 | } 132 | -------------------------------------------------------------------------------- /pkg/pipeline/sink/uploader/local.go: -------------------------------------------------------------------------------- 1 | // Copyright 2024 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package uploader 16 | 17 | import ( 18 | "io" 19 | "os" 20 | "path" 21 | 22 | "github.com/livekit/egress/pkg/config" 23 | "github.com/livekit/egress/pkg/types" 24 | ) 25 | 26 | type localUploader struct { 27 | prefix string 28 | } 29 | 30 | func newLocalUploader(c *config.StorageConfig) (*localUploader, error) { 31 | return &localUploader{prefix: c.Prefix}, nil 32 | } 33 | 34 | func (u *localUploader) upload(localFilepath, storageFilepath string, _ types.OutputType) (string, int64, error) { 35 | storageFilepath = path.Join(u.prefix, storageFilepath) 36 | 37 | stat, err := os.Stat(localFilepath) 38 | if err != nil { 39 | return "", 0, err 40 | } 41 | 42 | dir, _ := path.Split(storageFilepath) 43 | if err = os.MkdirAll(dir, 0755); err != nil { 44 | return "", 0, err 45 | } 46 | 47 | local, err := os.Open(localFilepath) 48 | if err != nil { 49 | return "", 0, err 50 | } 51 | defer local.Close() 52 | 53 | storage, err := os.Create(storageFilepath) 54 | if err != nil { 55 | return "", 0, err 56 | } 57 | defer storage.Close() 58 | 59 | _, err = io.Copy(storage, local) 60 | if err != nil { 61 | return "", 0, err 62 | } 63 | 64 | return storageFilepath, stat.Size(), nil 65 | } 66 | -------------------------------------------------------------------------------- /pkg/pipeline/sink/uploader/uploader.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package uploader 16 | 17 | import ( 18 | "os" 19 | "time" 20 | 21 | "github.com/livekit/egress/pkg/config" 22 | "github.com/livekit/egress/pkg/stats" 23 | "github.com/livekit/egress/pkg/types" 24 | "github.com/livekit/protocol/livekit" 25 | "github.com/livekit/protocol/logger" 26 | "github.com/livekit/psrpc" 27 | ) 28 | 29 | const ( 30 | maxRetries = 5 31 | minDelay = time.Millisecond * 100 32 | maxDelay = time.Second * 5 33 | ) 34 | 35 | type uploader interface { 36 | upload(string, string, types.OutputType) (string, int64, error) 37 | } 38 | 39 | type Uploader struct { 40 | primary uploader 41 | backup uploader 42 | primaryFailed bool 43 | info *livekit.EgressInfo 44 | monitor *stats.HandlerMonitor 45 | } 46 | 47 | func New(conf, backup *config.StorageConfig, monitor *stats.HandlerMonitor, info *livekit.EgressInfo) (*Uploader, error) { 48 | p, err := getUploader(conf) 49 | if err != nil { 50 | return nil, err 51 | } 52 | 53 | u := &Uploader{ 54 | primary: p, 55 | monitor: monitor, 56 | info: info, 57 | } 58 | 59 | if backup != nil { 60 | b, err := getUploader(backup) 61 | if err != nil { 62 | logger.Errorw("failed to create backup uploader", err) 63 | } else { 64 | u.backup = b 65 | } 66 | } 67 | 68 | return u, nil 69 | } 70 | 71 | func getUploader(conf *config.StorageConfig) (uploader, error) { 72 | switch { 73 | case conf == nil: 74 | return newLocalUploader(&config.StorageConfig{}) 75 | case conf.S3 != nil: 76 | return newS3Uploader(conf) 77 | case conf.GCP != nil: 78 | return newGCPUploader(conf) 79 | case conf.Azure != nil: 80 | return newAzureUploader(conf) 81 | case conf.AliOSS != nil: 82 | return newAliOSSUploader(conf) 83 | default: 84 | return newLocalUploader(conf) 85 | } 86 | } 87 | 88 | func (u *Uploader) Upload( 89 | localFilepath, storageFilepath string, 90 | outputType types.OutputType, 91 | deleteAfterUpload bool, 92 | ) (string, int64, error) { 93 | 94 | var primaryErr error 95 | if !u.primaryFailed { 96 | start := time.Now() 97 | location, size, err := u.primary.upload(localFilepath, storageFilepath, outputType) 98 | elapsed := time.Since(start) 99 | 100 | if err == nil { 101 | if u.monitor != nil { 102 | u.monitor.IncUploadCountSuccess(string(outputType), float64(elapsed.Milliseconds())) 103 | } 104 | if deleteAfterUpload { 105 | _ = os.Remove(localFilepath) 106 | } 107 | return location, size, nil 108 | } else { 109 | if u.monitor != nil { 110 | u.monitor.IncUploadCountFailure(string(outputType), float64(elapsed.Milliseconds())) 111 | } 112 | u.primaryFailed = true 113 | primaryErr = err 114 | } 115 | } 116 | 117 | if u.backup != nil { 118 | location, size, backupErr := u.backup.upload(localFilepath, storageFilepath, outputType) 119 | if backupErr == nil { 120 | if u.info != nil { 121 | u.info.SetBackupUsed() 122 | } 123 | if u.monitor != nil { 124 | u.monitor.IncBackupStorageWrites(string(outputType)) 125 | } 126 | if deleteAfterUpload { 127 | _ = os.Remove(localFilepath) 128 | } 129 | return location, size, nil 130 | } 131 | 132 | return "", 0, psrpc.NewErrorf(psrpc.InvalidArgument, 133 | "primary: %s\nbackup: %s", primaryErr.Error(), backupErr.Error()) 134 | } 135 | 136 | return "", 0, primaryErr 137 | } 138 | -------------------------------------------------------------------------------- /pkg/pipeline/sink/uploader/uploader_test.go: -------------------------------------------------------------------------------- 1 | package uploader 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "os" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/livekit/egress/pkg/config" 13 | "github.com/livekit/protocol/livekit" 14 | ) 15 | 16 | func TestUploader(t *testing.T) { 17 | key := os.Getenv("AWS_ACCESS_KEY") 18 | secret := os.Getenv("AWS_SECRET") 19 | region := os.Getenv("AWS_REGION") 20 | bucket := os.Getenv("AWS_BUCKET") 21 | 22 | primary := &config.StorageConfig{ 23 | S3: &config.S3Config{ 24 | AccessKey: "nonsense", 25 | Secret: "public", 26 | Region: "us-east-1", 27 | Bucket: "fake-bucket", 28 | }, 29 | } 30 | backup := &config.StorageConfig{ 31 | Prefix: "testProject", 32 | S3: &config.S3Config{ 33 | AccessKey: key, 34 | Secret: secret, 35 | Region: region, 36 | Bucket: bucket, 37 | }, 38 | GeneratePresignedUrl: true, 39 | } 40 | 41 | info := &livekit.EgressInfo{} 42 | u, err := New(primary, backup, nil, info) 43 | require.NoError(t, err) 44 | 45 | filepath := "uploader_test.go" 46 | storagePath := "uploader_test.go" 47 | 48 | location, size, err := u.Upload(filepath, storagePath, "test/plain", false) 49 | require.NoError(t, err) 50 | 51 | require.NotZero(t, size) 52 | require.NotEmpty(t, location) 53 | require.True(t, info.BackupStorageUsed) 54 | 55 | response, err := http.Get(location) 56 | require.NoError(t, err) 57 | defer response.Body.Close() 58 | 59 | require.Equal(t, http.StatusOK, response.StatusCode) 60 | b, err := io.ReadAll(response.Body) 61 | require.NoError(t, err) 62 | 63 | require.True(t, strings.HasPrefix(string(b), "package uploader")) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/pipeline/source/sdk/translator.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package sdk 16 | 17 | import ( 18 | "time" 19 | 20 | "github.com/pion/rtp" 21 | 22 | "github.com/livekit/livekit-server/pkg/sfu/buffer" 23 | "github.com/livekit/livekit-server/pkg/sfu/codecmunger" 24 | "github.com/livekit/protocol/logger" 25 | ) 26 | 27 | type Translator interface { 28 | Translate(*rtp.Packet) 29 | } 30 | 31 | // VP8 32 | 33 | type VP8Translator struct { 34 | logger logger.Logger 35 | 36 | firstPktPushed bool 37 | lastSN uint16 38 | vp8Munger *codecmunger.VP8 39 | } 40 | 41 | func NewVP8Translator(logger logger.Logger) *VP8Translator { 42 | return &VP8Translator{ 43 | logger: logger, 44 | vp8Munger: codecmunger.NewVP8(logger), 45 | } 46 | } 47 | 48 | func (t *VP8Translator) Translate(pkt *rtp.Packet) { 49 | defer func() { 50 | t.lastSN = pkt.SequenceNumber 51 | }() 52 | 53 | if len(pkt.Payload) == 0 { 54 | return 55 | } 56 | 57 | vp8Packet := buffer.VP8{} 58 | if err := vp8Packet.Unmarshal(pkt.Payload); err != nil { 59 | t.logger.Warnw("could not unmarshal VP8 packet", err) 60 | return 61 | } 62 | 63 | extPkt := &buffer.ExtPacket{ 64 | Packet: pkt, 65 | Arrival: time.Now().UnixNano(), 66 | Payload: vp8Packet, 67 | KeyFrame: vp8Packet.IsKeyFrame, 68 | VideoLayer: buffer.VideoLayer{ 69 | Spatial: -1, 70 | Temporal: int32(vp8Packet.TID), 71 | }, 72 | } 73 | 74 | if !t.firstPktPushed { 75 | t.firstPktPushed = true 76 | t.vp8Munger.SetLast(extPkt) 77 | } else { 78 | payload := make([]byte, 1460) 79 | incomingHeaderSize, header, err := t.vp8Munger.UpdateAndGet(extPkt, false, pkt.SequenceNumber != t.lastSN+1, extPkt.Temporal) 80 | if err != nil { 81 | t.logger.Warnw("could not update VP8 packet", err) 82 | return 83 | } 84 | copy(payload, header) 85 | n := copy(payload[len(header):], extPkt.Packet.Payload[incomingHeaderSize:]) 86 | pkt.Payload = payload[:len(header)+n] 87 | } 88 | } 89 | 90 | // Null 91 | 92 | type NullTranslator struct{} 93 | 94 | func NewNullTranslator() Translator { 95 | return &NullTranslator{} 96 | } 97 | 98 | func (t *NullTranslator) Translate(_ *rtp.Packet) {} 99 | -------------------------------------------------------------------------------- /pkg/pipeline/source/source.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package source 16 | 17 | import ( 18 | "context" 19 | 20 | "github.com/livekit/egress/pkg/config" 21 | "github.com/livekit/egress/pkg/errors" 22 | "github.com/livekit/egress/pkg/gstreamer" 23 | "github.com/livekit/egress/pkg/types" 24 | ) 25 | 26 | type Source interface { 27 | StartRecording() <-chan struct{} 28 | EndRecording() <-chan struct{} 29 | GetStartedAt() int64 30 | GetEndedAt() int64 31 | Close() 32 | } 33 | 34 | func New(ctx context.Context, p *config.PipelineConfig, callbacks *gstreamer.Callbacks) (Source, error) { 35 | switch p.SourceType { 36 | case types.SourceTypeWeb: 37 | return NewWebSource(ctx, p) 38 | 39 | case types.SourceTypeSDK: 40 | return NewSDKSource(ctx, p, callbacks) 41 | 42 | default: 43 | return nil, errors.ErrInvalidInput("request") 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /pkg/server/server_ipc.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package server 16 | 17 | import ( 18 | "context" 19 | "net/http" 20 | 21 | "google.golang.org/protobuf/types/known/emptypb" 22 | 23 | "github.com/livekit/egress/pkg/errors" 24 | "github.com/livekit/egress/pkg/ipc" 25 | "github.com/livekit/protocol/livekit" 26 | "github.com/livekit/protocol/logger" 27 | ) 28 | 29 | func (s *Server) HandlerReady(_ context.Context, req *ipc.HandlerReadyRequest) (*emptypb.Empty, error) { 30 | if err := s.HandlerStarted(req.EgressId); err != nil { 31 | return nil, err 32 | } 33 | 34 | return &emptypb.Empty{}, nil 35 | } 36 | 37 | func (s *Server) HandlerUpdate(_ context.Context, info *livekit.EgressInfo) (*emptypb.Empty, error) { 38 | if err := s.ioClient.UpdateEgress(context.Background(), info); err != nil { 39 | logger.Errorw("failed to update egress", err, "egressID", info.EgressId) 40 | } 41 | 42 | if info.ErrorCode == int32(http.StatusInternalServerError) { 43 | logger.Errorw("internal error, shutting down", errors.New(info.Error)) 44 | s.Shutdown(false, false) 45 | } 46 | 47 | return &emptypb.Empty{}, nil 48 | } 49 | 50 | func (s *Server) HandlerFinished(_ context.Context, req *ipc.HandlerFinishedRequest) (*emptypb.Empty, error) { 51 | if err := s.ioClient.UpdateEgress(context.Background(), req.Info); err != nil { 52 | logger.Errorw("failed to update egress", err, "egressID", req.EgressId) 53 | } 54 | 55 | if err := s.StoreProcessEndedMetrics(req.EgressId, req.Metrics); err != nil { 56 | logger.Errorw("failed to store metrics", err, "egressID", req.EgressId) 57 | } 58 | 59 | return &emptypb.Empty{}, nil 60 | } 61 | -------------------------------------------------------------------------------- /pkg/service/debug.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package service 16 | 17 | import ( 18 | "context" 19 | "fmt" 20 | "net/http" 21 | "strconv" 22 | "strings" 23 | 24 | "github.com/livekit/egress/pkg/errors" 25 | "github.com/livekit/egress/pkg/ipc" 26 | "github.com/livekit/protocol/logger" 27 | "github.com/livekit/protocol/pprof" 28 | "github.com/livekit/psrpc" 29 | ) 30 | 31 | const ( 32 | gstPipelineDotFileApp = "gst_pipeline" 33 | pprofApp = "pprof" 34 | ) 35 | 36 | type DebugService struct { 37 | pm *ProcessManager 38 | } 39 | 40 | func NewDebugService(pm *ProcessManager) *DebugService { 41 | return &DebugService{ 42 | pm: pm, 43 | } 44 | } 45 | 46 | func (s *DebugService) StartDebugHandlers(port int) { 47 | if port == 0 { 48 | logger.Debugw("debug handler disabled") 49 | return 50 | } 51 | 52 | mux := http.NewServeMux() 53 | mux.HandleFunc(fmt.Sprintf("/%s/", gstPipelineDotFileApp), s.handleGstPipelineDotFile) 54 | mux.HandleFunc(fmt.Sprintf("/%s/", pprofApp), s.handlePProf) 55 | 56 | go func() { 57 | addr := fmt.Sprintf(":%d", port) 58 | logger.Debugw(fmt.Sprintf("starting debug handler on address %s", addr)) 59 | _ = http.ListenAndServe(addr, mux) 60 | }() 61 | } 62 | 63 | // URL path format is "///" 64 | func (s *DebugService) handleGstPipelineDotFile(w http.ResponseWriter, r *http.Request) { 65 | pathElements := strings.Split(r.URL.Path, "/") 66 | if len(pathElements) < 3 { 67 | http.Error(w, "malformed url", http.StatusNotFound) 68 | return 69 | } 70 | 71 | egressID := pathElements[2] 72 | dotFile, err := s.GetGstPipelineDotFile(egressID) 73 | if err != nil { 74 | http.Error(w, err.Error(), getErrorCode(err)) 75 | return 76 | } 77 | _, _ = w.Write([]byte(dotFile)) 78 | } 79 | 80 | func (s *DebugService) GetGstPipelineDotFile(egressID string) (string, error) { 81 | c, err := s.pm.GetGRPCClient(egressID) 82 | if err != nil { 83 | return "", err 84 | } 85 | 86 | res, err := c.GetPipelineDot(context.Background(), &ipc.GstPipelineDebugDotRequest{}) 87 | if err != nil { 88 | return "", err 89 | } 90 | return res.DotFile, nil 91 | } 92 | 93 | // URL path format is "///" or "//" to profile the service 94 | func (s *DebugService) handlePProf(w http.ResponseWriter, r *http.Request) { 95 | var err error 96 | var b []byte 97 | 98 | timeout, _ := strconv.Atoi(r.URL.Query().Get("timeout")) 99 | debug, _ := strconv.Atoi(r.URL.Query().Get("debug")) 100 | 101 | pathElements := strings.Split(r.URL.Path, "/") 102 | switch len(pathElements) { 103 | case 3: 104 | // profile main service 105 | b, err = pprof.GetProfileData(context.Background(), pathElements[2], timeout, debug) 106 | 107 | case 4: 108 | egressID := pathElements[2] 109 | c, err := s.pm.GetGRPCClient(egressID) 110 | if err != nil { 111 | http.Error(w, "handler not found", http.StatusNotFound) 112 | return 113 | } 114 | 115 | res, err := c.GetPProf(context.Background(), &ipc.PProfRequest{ 116 | ProfileName: pathElements[3], 117 | Timeout: int32(timeout), 118 | Debug: int32(debug), 119 | }) 120 | if err == nil { 121 | b = res.PprofFile 122 | } 123 | 124 | default: 125 | http.Error(w, "malformed url", http.StatusNotFound) 126 | return 127 | } 128 | 129 | if err == nil { 130 | w.Header().Add("Content-Type", "application/octet-stream") 131 | _, err = w.Write(b) 132 | } 133 | if err != nil { 134 | http.Error(w, err.Error(), getErrorCode(err)) 135 | return 136 | } 137 | } 138 | 139 | func getErrorCode(err error) int { 140 | var e psrpc.Error 141 | 142 | switch { 143 | case errors.As(err, &e): 144 | return e.ToHttp() 145 | case err == nil: 146 | return http.StatusOK 147 | default: 148 | return http.StatusInternalServerError 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /pkg/service/metrics.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package service 16 | 17 | import ( 18 | "context" 19 | "net/http" 20 | "strings" 21 | "sync" 22 | 23 | "github.com/prometheus/client_golang/prometheus" 24 | "github.com/prometheus/client_golang/prometheus/promhttp" 25 | dto "github.com/prometheus/client_model/go" 26 | "github.com/prometheus/common/expfmt" 27 | "golang.org/x/exp/maps" 28 | 29 | "github.com/livekit/protocol/logger" 30 | "github.com/livekit/protocol/tracer" 31 | ) 32 | 33 | type MetricsService struct { 34 | pm *ProcessManager 35 | 36 | mu sync.Mutex 37 | pendingMetrics []*dto.MetricFamily 38 | } 39 | 40 | func NewMetricsService(pm *ProcessManager) *MetricsService { 41 | return &MetricsService{ 42 | pm: pm, 43 | } 44 | } 45 | 46 | func (s *MetricsService) PromHandler() http.Handler { 47 | return promhttp.InstrumentMetricHandler( 48 | prometheus.DefaultRegisterer, promhttp.HandlerFor(s.CreateGatherer(), promhttp.HandlerOpts{}), 49 | ) 50 | } 51 | 52 | func (s *MetricsService) CreateGatherer() prometheus.Gatherer { 53 | return prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) { 54 | _, span := tracer.Start(context.Background(), "Service.GathererOfHandlerMetrics") 55 | defer span.End() 56 | 57 | gatherers := prometheus.Gatherers{} 58 | // Include the default repo 59 | gatherers = append(gatherers, prometheus.DefaultGatherer) 60 | // Include Process ended ms 61 | gatherers = append(gatherers, prometheus.GathererFunc(func() ([]*dto.MetricFamily, error) { 62 | s.mu.Lock() 63 | m := s.pendingMetrics 64 | s.pendingMetrics = nil 65 | s.mu.Unlock() 66 | return m, nil 67 | })) 68 | 69 | gatherers = append(gatherers, s.pm.GetGatherers()...) 70 | 71 | return gatherers.Gather() 72 | }) 73 | } 74 | 75 | func (s *MetricsService) StoreProcessEndedMetrics(egressID string, metrics string) error { 76 | m, err := deserializeMetrics(egressID, metrics) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | s.mu.Lock() 82 | s.pendingMetrics = append(s.pendingMetrics, m...) 83 | s.mu.Unlock() 84 | 85 | return nil 86 | } 87 | 88 | func deserializeMetrics(egressID string, s string) ([]*dto.MetricFamily, error) { 89 | parser := &expfmt.TextParser{} 90 | families, err := parser.TextToMetricFamilies(strings.NewReader(s)) 91 | if err != nil { 92 | logger.Warnw("failed to parse ms from handler", err, "egress_id", egressID) 93 | return make([]*dto.MetricFamily, 0), nil // don't return an error, just skip this handler 94 | } 95 | 96 | // Add an egress_id label to every metric all the families, if it doesn't already have one 97 | applyDefaultLabel(egressID, families) 98 | 99 | return maps.Values(families), nil 100 | } 101 | 102 | func applyDefaultLabel(egressID string, families map[string]*dto.MetricFamily) { 103 | egressIDLabel := "egress_id" 104 | egressLabelPair := &dto.LabelPair{ 105 | Name: &egressIDLabel, 106 | Value: &egressID, 107 | } 108 | for _, family := range families { 109 | for _, metric := range family.Metric { 110 | if metric.Label == nil { 111 | metric.Label = make([]*dto.LabelPair, 0) 112 | } 113 | found := false 114 | for _, label := range metric.Label { 115 | if label.GetName() == "egress_id" { 116 | found = true 117 | break 118 | } 119 | } 120 | if !found { 121 | metric.Label = append(metric.Label, egressLabelPair) 122 | } 123 | } 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /pkg/stats/handler.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package stats 16 | 17 | import ( 18 | "github.com/prometheus/client_golang/prometheus" 19 | ) 20 | 21 | type HandlerMonitor struct { 22 | uploadsCounter *prometheus.CounterVec 23 | uploadsResponseTime *prometheus.HistogramVec 24 | backupCounter *prometheus.CounterVec 25 | } 26 | 27 | func NewHandlerMonitor(nodeId string, clusterId string, egressId string) *HandlerMonitor { 28 | m := &HandlerMonitor{} 29 | 30 | constantLabels := prometheus.Labels{"node_id": nodeId, "cluster_id": clusterId, "egress_id": egressId} 31 | 32 | m.uploadsCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 33 | Namespace: "livekit", 34 | Subsystem: "egress", 35 | Name: "pipeline_uploads", 36 | Help: "Number of uploads per pipeline with type and status labels", 37 | ConstLabels: constantLabels, 38 | }, []string{"type", "status"}) // type: file, manifest, segment, liveplaylist, playlist; status: success,failure 39 | 40 | m.uploadsResponseTime = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 41 | Namespace: "livekit", 42 | Subsystem: "egress", 43 | Name: "pipline_upload_response_time_ms", 44 | Help: "A histogram of latencies for upload requests in milliseconds.", 45 | Buckets: []float64{10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000, 15000, 20000, 30000}, 46 | ConstLabels: constantLabels, 47 | }, []string{"type", "status"}) 48 | 49 | m.backupCounter = prometheus.NewCounterVec(prometheus.CounterOpts{ 50 | Namespace: "livekit", 51 | Subsystem: "egress", 52 | Name: "backup_storage_writes", 53 | Help: "number of writes to backup storage location by output type", 54 | ConstLabels: constantLabels, 55 | }, []string{"output_type"}) 56 | 57 | prometheus.MustRegister(m.uploadsCounter, m.uploadsResponseTime, m.backupCounter) 58 | 59 | return m 60 | } 61 | 62 | func (m *HandlerMonitor) IncUploadCountSuccess(uploadType string, elapsed float64) { 63 | labels := prometheus.Labels{"type": uploadType, "status": "success"} 64 | m.uploadsCounter.With(labels).Add(1) 65 | m.uploadsResponseTime.With(labels).Observe(elapsed) 66 | } 67 | 68 | func (m *HandlerMonitor) IncUploadCountFailure(uploadType string, elapsed float64) { 69 | labels := prometheus.Labels{"type": uploadType, "status": "failure"} 70 | m.uploadsCounter.With(labels).Add(1) 71 | m.uploadsResponseTime.With(labels).Observe(elapsed) 72 | } 73 | 74 | func (m *HandlerMonitor) IncBackupStorageWrites(outputType string) { 75 | m.backupCounter.With(prometheus.Labels{"output_type": outputType}).Add(1) 76 | } 77 | 78 | func (m *HandlerMonitor) RegisterSegmentsChannelSizeGauge(nodeId string, clusterId string, egressId string, channelSizeFunction func() float64) { 79 | segmentsUploadsGauge := prometheus.NewGaugeFunc( 80 | prometheus.GaugeOpts{ 81 | Namespace: "livekit", 82 | Subsystem: "egress", 83 | Name: "segments_uploads_channel_size", 84 | Help: "number of segment uploads pending in channel", 85 | ConstLabels: prometheus.Labels{"node_id": nodeId, "cluster_id": clusterId, "egress_id": egressId}, 86 | }, channelSizeFunction) 87 | prometheus.MustRegister(segmentsUploadsGauge) 88 | } 89 | 90 | func (m *HandlerMonitor) RegisterPlaylistChannelSizeGauge(nodeId string, clusterId string, egressId string, channelSizeFunction func() float64) { 91 | playlistUploadsGauge := prometheus.NewGaugeFunc( 92 | prometheus.GaugeOpts{ 93 | Namespace: "livekit", 94 | Subsystem: "egress", 95 | Name: "playlist_uploads_channel_size", 96 | Help: "number of playlist updates pending in channel", 97 | ConstLabels: prometheus.Labels{"node_id": nodeId, "cluster_id": clusterId, "egress_id": egressId}, 98 | }, channelSizeFunction) 99 | prometheus.MustRegister(playlistUploadsGauge) 100 | } 101 | -------------------------------------------------------------------------------- /pkg/stats/monitor_prom.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package stats 16 | 17 | import ( 18 | "github.com/prometheus/client_golang/prometheus" 19 | 20 | "github.com/livekit/protocol/livekit" 21 | "github.com/livekit/protocol/rpc" 22 | ) 23 | 24 | func (m *Monitor) initPrometheus() { 25 | promNodeAvailable := prometheus.NewGaugeFunc(prometheus.GaugeOpts{ 26 | Namespace: "livekit", 27 | Subsystem: "egress", 28 | Name: "available", 29 | ConstLabels: prometheus.Labels{"node_id": m.nodeID, "cluster_id": m.clusterID}, 30 | }, m.promIsIdle) 31 | 32 | promCanAcceptRequest := prometheus.NewGaugeFunc(prometheus.GaugeOpts{ 33 | Namespace: "livekit", 34 | Subsystem: "egress", 35 | Name: "can_accept_request", 36 | ConstLabels: prometheus.Labels{"node_id": m.nodeID, "cluster_id": m.clusterID}, 37 | }, m.promCanAcceptRequest) 38 | 39 | promIsDisabled := prometheus.NewGaugeFunc(prometheus.GaugeOpts{ 40 | Namespace: "livekit", 41 | Subsystem: "egress", 42 | Name: "is_disabled", 43 | ConstLabels: prometheus.Labels{"node_id": m.nodeID, "cluster_id": m.clusterID}, 44 | }, m.promIsDisabled) 45 | 46 | promIsTerminating := prometheus.NewGaugeFunc(prometheus.GaugeOpts{ 47 | Namespace: "livekit", 48 | Subsystem: "egress", 49 | Name: "is_terminating", 50 | ConstLabels: prometheus.Labels{"node_id": m.nodeID, "cluster_id": m.clusterID}, 51 | }, m.promIsTerminating) 52 | 53 | m.promCPULoad = prometheus.NewGauge(prometheus.GaugeOpts{ 54 | Namespace: "livekit", 55 | Subsystem: "node", 56 | Name: "cpu_load", 57 | ConstLabels: prometheus.Labels{"node_id": m.nodeID, "node_type": "EGRESS", "cluster_id": m.clusterID}, 58 | }) 59 | 60 | m.requestGauge = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 61 | Namespace: "livekit", 62 | Subsystem: "egress", 63 | Name: "requests", 64 | ConstLabels: prometheus.Labels{"node_id": m.nodeID, "cluster_id": m.clusterID}, 65 | }, []string{"type"}) 66 | 67 | prometheus.MustRegister(promNodeAvailable, promCanAcceptRequest, promIsDisabled, promIsTerminating, m.promCPULoad, m.requestGauge) 68 | } 69 | 70 | func (m *Monitor) promIsIdle() float64 { 71 | if m.svc.IsIdle() { 72 | return 1 73 | } 74 | return 0 75 | } 76 | 77 | func (m *Monitor) promCanAcceptRequest() float64 { 78 | m.mu.Lock() 79 | _, canAccept := m.canAcceptRequestLocked(&rpc.StartEgressRequest{ 80 | Request: &rpc.StartEgressRequest_Web{Web: &livekit.WebEgressRequest{}}, 81 | }) 82 | m.mu.Unlock() 83 | 84 | if !m.svc.IsDisabled() && canAccept { 85 | return 1 86 | } 87 | return 0 88 | } 89 | 90 | func (m *Monitor) promIsDisabled() float64 { 91 | if m.svc.IsDisabled() { 92 | return 1 93 | } 94 | return 0 95 | } 96 | 97 | func (m *Monitor) promIsTerminating() float64 { 98 | if m.svc.IsTerminating() { 99 | return 1 100 | } 101 | return 0 102 | } 103 | -------------------------------------------------------------------------------- /pkg/types/types_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package types 16 | 17 | import ( 18 | "testing" 19 | 20 | "github.com/stretchr/testify/require" 21 | ) 22 | 23 | func TestGetMapIntersection(t *testing.T) { 24 | list := make(map[MimeType]bool) 25 | 26 | res := GetMapIntersection(list, CodecCompatibility[OutputTypeUnknownFile]) 27 | require.Empty(t, res) 28 | 29 | list[MimeTypeH264] = true 30 | res = GetMapIntersection(list, CodecCompatibility[OutputTypeOGG]) 31 | require.Empty(t, res) 32 | 33 | list[MimeTypeVP8] = true 34 | res = GetMapIntersection(list, CodecCompatibility[OutputTypeMP4]) 35 | require.Equal(t, map[MimeType]bool{MimeTypeH264: true}, res) 36 | } 37 | 38 | func TestGetOutputTypesCompatibleWithCodecs(t *testing.T) { 39 | outputTypes := make([]OutputType, 0) 40 | audioCodecs := make(map[MimeType]bool) 41 | videoCodecs := make(map[MimeType]bool) 42 | 43 | res := GetOutputTypeCompatibleWithCodecs(outputTypes, audioCodecs, videoCodecs) 44 | require.Empty(t, res) 45 | 46 | outputTypes = append(outputTypes, OutputTypeOGG, OutputTypeMP4) 47 | res = GetOutputTypeCompatibleWithCodecs(outputTypes, audioCodecs, videoCodecs) 48 | require.Empty(t, res) 49 | 50 | audioCodecs[MimeTypeAAC] = true 51 | outputTypes = append(outputTypes, OutputTypeMP4) 52 | res = GetOutputTypeCompatibleWithCodecs(outputTypes, audioCodecs, videoCodecs) 53 | require.Empty(t, res) 54 | 55 | videoCodecs[MimeTypeVP8] = true 56 | outputTypes = append(outputTypes, OutputTypeMP4) 57 | res = GetOutputTypeCompatibleWithCodecs(outputTypes, audioCodecs, videoCodecs) 58 | require.Empty(t, res) 59 | 60 | videoCodecs[MimeTypeH264] = true 61 | outputTypes = append(outputTypes, OutputTypeMP4) 62 | res = GetOutputTypeCompatibleWithCodecs(outputTypes, audioCodecs, videoCodecs) 63 | require.Equal(t, OutputTypeMP4, res) 64 | } 65 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ], 6 | "commitBody": "Generated by renovateBot", 7 | "packageRules": [ 8 | { 9 | "matchManagers": ["github-actions"], 10 | "groupName": "github workflows" 11 | }, 12 | { 13 | "matchManagers": ["dockerfile"], 14 | "groupName": "docker deps" 15 | }, 16 | { 17 | "matchManagers": ["npm"], 18 | "groupName": "npm deps" 19 | }, 20 | { 21 | "matchManagers": ["gomod"], 22 | "groupName": "go deps" 23 | }, 24 | { 25 | "matchPackagePrefixes": ["github.com/grafov/m3u8"], 26 | "enabled": false 27 | } 28 | ], 29 | "postUpdateOptions": [ 30 | "gomodTidy" 31 | ], 32 | "schedule": ["on sunday"], 33 | "updateNotScheduled": false 34 | } 35 | -------------------------------------------------------------------------------- /template-default/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /template-default/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": true, 5 | "tabWidth": 2, 6 | "printWidth": 100, 7 | "plugins": [], 8 | "pluginSearchDirs": ["."] 9 | } 10 | -------------------------------------------------------------------------------- /template-default/README.md: -------------------------------------------------------------------------------- 1 | # Default LiveKit Recording Templates 2 | 3 | This repo contains the default recording template used with LiveKit Egress. The templates are deployed alongside and served by the egress service. 4 | 5 | See docs [here](https://docs.livekit.io/guides/egress/room-composite/#default-layouts) 6 | -------------------------------------------------------------------------------- /template-default/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "livekit-egress-web", 3 | "homepage": "https://livekit.io", 4 | "description": "Default templates for RoomComposite egress", 5 | "version": "0.2.1", 6 | "private": true, 7 | "dependencies": { 8 | "@babel/runtime": "^7.27.1", 9 | "@livekit/components-core": "^0.11.11", 10 | "@livekit/components-react": "^2.9.4", 11 | "@livekit/components-styles": "^1.1.5", 12 | "@livekit/egress-sdk": "^0.2.1", 13 | "livekit-client": "^2.13.0", 14 | "react": "^19.1.0", 15 | "react-dom": "^19.1.0", 16 | "react-scripts": "5.0.1" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "test": "react-scripts test", 22 | "eject": "react-scripts eject" 23 | }, 24 | "eslintConfig": { 25 | "extends": [ 26 | "react-app", 27 | "react-app/jest" 28 | ] 29 | }, 30 | "browserslist": { 31 | "production": [ 32 | ">0.2%", 33 | "not dead", 34 | "not op_mini all" 35 | ], 36 | "development": [ 37 | "last 1 chrome version", 38 | "last 1 firefox version", 39 | "last 1 safari version" 40 | ] 41 | }, 42 | "resolutions": { 43 | "nth-check": "^2.1.1" 44 | }, 45 | "devDependencies": { 46 | "@testing-library/jest-dom": "^5.17.0", 47 | "@testing-library/react": "^14.3.1", 48 | "@testing-library/user-event": "^14.6.1", 49 | "@types/jest": "^29.5.14", 50 | "@types/node": "^18.19.100", 51 | "@types/react": "^18.3.21", 52 | "@types/react-dom": "^18.3.7", 53 | "@types/react-router-dom": "^5.3.3", 54 | "gh-pages": "^5.0.0", 55 | "typescript": "^5.8.3" 56 | } 57 | } -------------------------------------------------------------------------------- /template-default/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit/egress/5f3ebf8e84f78c96ed4bdf90b0e15136887c0ab5/template-default/public/favicon.ico -------------------------------------------------------------------------------- /template-default/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | LiveKit Egress 25 | 26 | 27 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /template-default/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/livekit/egress/5f3ebf8e84f78c96ed4bdf90b0e15136887c0ab5/template-default/public/logo.png -------------------------------------------------------------------------------- /template-default/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /template-default/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "livekit-egress-web", 3 | "name": "Web template for LiveKit Egress", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo.png", 12 | "type": "image/png", 13 | "sizes": "150x150" 14 | } 15 | ], 16 | "start_url": ".", 17 | "display": "standalone", 18 | "theme_color": "#000000", 19 | "background_color": "#ffffff" 20 | } 21 | -------------------------------------------------------------------------------- /template-default/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /template-default/src/App.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 LiveKit, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | body { 18 | padding: 0; 19 | font-family: Avenir, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 20 | 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; 21 | -webkit-font-smoothing: antialiased; 22 | -moz-osx-font-smoothing: grayscale; 23 | background: black; 24 | color: rgb(211, 210, 210); 25 | box-sizing: border-box; 26 | margin: 0; 27 | height: 100vh; 28 | font-size: 12px; 29 | overflow: hidden; 30 | } 31 | 32 | .light { 33 | background: white; 34 | } 35 | 36 | .roomContainer { 37 | height: 100vh; 38 | } 39 | 40 | .error { 41 | color: red; 42 | } 43 | 44 | .lk-grid-layout-wrapper { 45 | height: 100%; 46 | } 47 | 48 | .lk-focus-layout { 49 | height: 100%; 50 | } 51 | 52 | /* things like name, connection quality, etc make less sense in a recording, hide for now */ 53 | .lk-participant-metadata { 54 | display: none; 55 | } 56 | -------------------------------------------------------------------------------- /template-default/src/App.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 LiveKit, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import '@livekit/components-styles'; 18 | import '@livekit/components-styles/prefabs'; 19 | import EgressHelper from '@livekit/egress-sdk'; 20 | import './App.css'; 21 | import RoomPage from './Room'; 22 | 23 | function App() { 24 | return ( 25 |
26 | 32 |
33 | ); 34 | } 35 | 36 | export default App; 37 | -------------------------------------------------------------------------------- /template-default/src/SingleSpeakerLayout.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 LiveKit, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { TrackReference, useVisualStableUpdate, VideoTrack } from '@livekit/components-react'; 18 | import { LayoutProps } from './common'; 19 | 20 | const SingleSpeakerLayout = ({ tracks: references }: LayoutProps) => { 21 | const sortedReferences = useVisualStableUpdate(references, 1); 22 | if (sortedReferences.length === 0) { 23 | return null; 24 | } 25 | return ; 26 | }; 27 | 28 | export default SingleSpeakerLayout; 29 | -------------------------------------------------------------------------------- /template-default/src/SpeakerLayout.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 LiveKit, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { TrackReference } from '@livekit/components-core'; 18 | import { 19 | CarouselLayout, 20 | FocusLayout, 21 | ParticipantTile, 22 | VideoTrack, 23 | useVisualStableUpdate, 24 | } from '@livekit/components-react'; 25 | import { LayoutProps } from './common'; 26 | 27 | const SpeakerLayout = ({ tracks: references }: LayoutProps) => { 28 | const sortedTracks = useVisualStableUpdate(references, 1); 29 | const mainTrack = sortedTracks.shift(); 30 | const remainingTracks = useVisualStableUpdate(sortedTracks, 3); 31 | 32 | if (!mainTrack) { 33 | return <>; 34 | } else if (remainingTracks.length === 0) { 35 | const trackRef = mainTrack as TrackReference; 36 | return ; 37 | } 38 | 39 | return ( 40 |
41 | 42 | 43 | 44 | 45 |
46 | ); 47 | }; 48 | 49 | export default SpeakerLayout; 50 | -------------------------------------------------------------------------------- /template-default/src/common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 LiveKit, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { TrackReference } from '@livekit/components-core'; 18 | 19 | export interface LayoutProps { 20 | tracks: TrackReference[]; 21 | } 22 | -------------------------------------------------------------------------------- /template-default/src/index.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 LiveKit, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | body { 18 | margin: 0; 19 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 20 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 21 | sans-serif; 22 | -webkit-font-smoothing: antialiased; 23 | -moz-osx-font-smoothing: grayscale; 24 | } 25 | 26 | code { 27 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 28 | monospace; 29 | } 30 | -------------------------------------------------------------------------------- /template-default/src/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 LiveKit, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import React from 'react'; 18 | import { createRoot } from 'react-dom/client'; 19 | import App from './App'; 20 | 21 | const container = document.getElementById('root'); 22 | if (!container) throw new Error('Failed to find the root element'); 23 | 24 | const root = createRoot(container); 25 | 26 | root.render( 27 | 28 | 29 | , 30 | ); 31 | -------------------------------------------------------------------------------- /template-default/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 LiveKit, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /// 18 | -------------------------------------------------------------------------------- /template-default/tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/*.ts", "src/*.tsx", "src/*.jsx", "src/*.js", ".eslintrc.js"] 4 | } 5 | -------------------------------------------------------------------------------- /template-default/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /template-sdk/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /template-sdk/.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | node_modules 3 | tsconfig.json 4 | .prettierrc 5 | -------------------------------------------------------------------------------- /template-sdk/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "semi": true, 5 | "tabWidth": 2, 6 | "printWidth": 100, 7 | "plugins": [], 8 | "pluginSearchDirs": ["."] 9 | } 10 | -------------------------------------------------------------------------------- /template-sdk/README.md: -------------------------------------------------------------------------------- 1 | # Egress Recording Template SDK 2 | 3 | This lightweight SDK makes it simple to build your own Room Composite templates. 4 | 5 | ## Docs 6 | 7 | See [custom egress template docs](https://docs.livekit.io/guides/egress/custom-template/) 8 | -------------------------------------------------------------------------------- /template-sdk/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@livekit/egress-sdk", 3 | "version": "0.2.1", 4 | "description": "A lightweight SDK for developing RoomComposite templates", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "source": "src/index.ts", 8 | "repository": "https://github.com/livekit/egress", 9 | "author": "David Zhao ", 10 | "license": "Apache-2.0", 11 | "scripts": { 12 | "build": "tsc" 13 | }, 14 | "devDependencies": { 15 | "livekit-client": "^2.12.0", 16 | "prettier": "^2.8.8", 17 | "typescript": "^5.8.3" 18 | }, 19 | "peerDependencies": { 20 | "livekit-client": "^1.15.13 || ^2.7.5" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /template-sdk/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '9.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | importers: 8 | 9 | .: 10 | devDependencies: 11 | livekit-client: 12 | specifier: ^2.12.0 13 | version: 2.12.0 14 | prettier: 15 | specifier: ^2.8.8 16 | version: 2.8.8 17 | typescript: 18 | specifier: ^5.8.3 19 | version: 5.8.3 20 | 21 | packages: 22 | 23 | '@bufbuild/protobuf@1.10.1': 24 | resolution: {integrity: sha512-wJ8ReQbHxsAfXhrf9ixl0aYbZorRuOWpBNzm8pL8ftmSxQx/wnJD5Eg861NwJU/czy2VXFIebCeZnZrI9rktIQ==} 25 | 26 | '@livekit/mutex@1.1.1': 27 | resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} 28 | 29 | '@livekit/protocol@1.38.0': 30 | resolution: {integrity: sha512-XX6ulvsE1XCN18LVf3ydHN7Ri1Z1M1P5dQdjnm5nVDsSqUL12Vbo/4RKcRlCEXAg2qB62mKjcaVLXVwkfXggkg==} 31 | 32 | events@3.3.0: 33 | resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} 34 | engines: {node: '>=0.8.x'} 35 | 36 | livekit-client@2.12.0: 37 | resolution: {integrity: sha512-W1dcH+TSfZ7mnWm3jZvFUzi7/FbjMJM2HtLh3+uZx5d3M1WcBa4LCKY581RTd1NaD+gwtMDY0D7RnaOKlfpWeQ==} 38 | 39 | loglevel@1.9.2: 40 | resolution: {integrity: sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==} 41 | engines: {node: '>= 0.6.0'} 42 | 43 | prettier@2.8.8: 44 | resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} 45 | engines: {node: '>=10.13.0'} 46 | hasBin: true 47 | 48 | rxjs@7.8.2: 49 | resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} 50 | 51 | sdp-transform@2.15.0: 52 | resolution: {integrity: sha512-KrOH82c/W+GYQ0LHqtr3caRpM3ITglq3ljGUIb8LTki7ByacJZ9z+piSGiwZDsRyhQbYBOBJgr2k6X4BZXi3Kw==} 53 | hasBin: true 54 | 55 | sdp@3.2.0: 56 | resolution: {integrity: sha512-d7wDPgDV3DDiqulJjKiV2865wKsJ34YI+NDREbm+FySq6WuKOikwyNQcm+doLAZ1O6ltdO0SeKle2xMpN3Brgw==} 57 | 58 | ts-debounce@4.0.0: 59 | resolution: {integrity: sha512-+1iDGY6NmOGidq7i7xZGA4cm8DAa6fqdYcvO5Z6yBevH++Bdo9Qt/mN0TzHUgcCcKv1gmh9+W5dHqz8pMWbCbg==} 60 | 61 | tslib@2.8.1: 62 | resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} 63 | 64 | typed-emitter@2.1.0: 65 | resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==} 66 | 67 | typescript@5.8.3: 68 | resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} 69 | engines: {node: '>=14.17'} 70 | hasBin: true 71 | 72 | webrtc-adapter@9.0.3: 73 | resolution: {integrity: sha512-5fALBcroIl31OeXAdd1YUntxiZl1eHlZZWzNg3U4Fn+J9/cGL3eT80YlrsWGvj2ojuz1rZr2OXkgCzIxAZ7vRQ==} 74 | engines: {node: '>=6.0.0', npm: '>=3.10.0'} 75 | 76 | snapshots: 77 | 78 | '@bufbuild/protobuf@1.10.1': {} 79 | 80 | '@livekit/mutex@1.1.1': {} 81 | 82 | '@livekit/protocol@1.38.0': 83 | dependencies: 84 | '@bufbuild/protobuf': 1.10.1 85 | 86 | events@3.3.0: {} 87 | 88 | livekit-client@2.12.0: 89 | dependencies: 90 | '@livekit/mutex': 1.1.1 91 | '@livekit/protocol': 1.38.0 92 | events: 3.3.0 93 | loglevel: 1.9.2 94 | sdp-transform: 2.15.0 95 | ts-debounce: 4.0.0 96 | tslib: 2.8.1 97 | typed-emitter: 2.1.0 98 | webrtc-adapter: 9.0.3 99 | 100 | loglevel@1.9.2: {} 101 | 102 | prettier@2.8.8: {} 103 | 104 | rxjs@7.8.2: 105 | dependencies: 106 | tslib: 2.8.1 107 | optional: true 108 | 109 | sdp-transform@2.15.0: {} 110 | 111 | sdp@3.2.0: {} 112 | 113 | ts-debounce@4.0.0: {} 114 | 115 | tslib@2.8.1: {} 116 | 117 | typed-emitter@2.1.0: 118 | optionalDependencies: 119 | rxjs: 7.8.2 120 | 121 | typescript@5.8.3: {} 122 | 123 | webrtc-adapter@9.0.3: 124 | dependencies: 125 | sdp: 3.2.0 126 | -------------------------------------------------------------------------------- /template-sdk/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2023 LiveKit, Inc. 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import { ParticipantEvent, Room, RoomEvent } from 'livekit-client'; 18 | 19 | const EgressHelper = { 20 | /** 21 | * RoomComposite will pass URL to your livekit's server instance. 22 | * @returns 23 | */ 24 | getLiveKitURL(): string { 25 | const url = getURLParam('url'); 26 | if (!url) { 27 | throw new Error('url is not found in query string'); 28 | } 29 | return url; 30 | }, 31 | 32 | /** 33 | * 34 | * @returns access token to pass to `Room.connect` 35 | */ 36 | getAccessToken(): string { 37 | const token = getURLParam('token'); 38 | if (!token) { 39 | throw new Error('token is not found in query string'); 40 | } 41 | return token; 42 | }, 43 | 44 | /** 45 | * the current desired layout. layout can be changed dynamically with [Egress.UpdateLayout](https://github.com/livekit/protocol/blob/main/livekit_egress.proto#L15) 46 | * @returns 47 | */ 48 | getLayout(): string { 49 | if (state.layout) { 50 | return state.layout; 51 | } 52 | const layout = getURLParam('layout'); 53 | return layout ?? ''; 54 | }, 55 | 56 | /** 57 | * Call when successfully connected to the room 58 | * @param room 59 | */ 60 | setRoom(room: Room) { 61 | if (currentRoom) { 62 | currentRoom.off(RoomEvent.Disconnected, EgressHelper.endRecording); 63 | } 64 | 65 | currentRoom = room; 66 | currentRoom.localParticipant.on(ParticipantEvent.ParticipantMetadataChanged, onMetadataChanged); 67 | currentRoom.on(RoomEvent.Disconnected, EgressHelper.endRecording); 68 | onMetadataChanged(); 69 | }, 70 | 71 | /** 72 | * Starts recording the room that's passed in 73 | */ 74 | startRecording() { 75 | console.log('START_RECORDING'); 76 | }, 77 | 78 | /** 79 | * Finishes recording the room, by default, it'll end automatically finish 80 | * when all other participants have left the room. 81 | */ 82 | endRecording() { 83 | currentRoom = undefined; 84 | console.log('END_RECORDING'); 85 | }, 86 | 87 | /** 88 | * Registers a callback to listen to layout changes. 89 | * @param f 90 | */ 91 | onLayoutChanged(f: (layout: string) => void) { 92 | layoutChangedCallback = f; 93 | }, 94 | }; 95 | 96 | let currentRoom: Room | undefined; 97 | let layoutChangedCallback: (layout: string) => void | undefined; 98 | let state: TemplateState = { 99 | layout: '', 100 | }; 101 | 102 | interface TemplateState { 103 | layout: string; 104 | } 105 | 106 | function onMetadataChanged() { 107 | // for recorder, metadata is a JSON object containing layout 108 | const metadata = currentRoom?.localParticipant.metadata; 109 | if (metadata) { 110 | const newState: TemplateState = JSON.parse(metadata); 111 | if (newState && newState.layout !== state.layout) { 112 | state = newState; 113 | layoutChangedCallback(state.layout); 114 | } 115 | } 116 | } 117 | 118 | function getURLParam(name: string): string | null { 119 | const query = new URLSearchParams(window.location.search); 120 | return query.get(name); 121 | } 122 | 123 | export default EgressHelper; 124 | -------------------------------------------------------------------------------- /template-sdk/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 4 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ 5 | "outDir": "dist", 6 | "declaration": true, 7 | "sourceMap": true, 8 | "strict": true, /* Enable all strict type-checking options. */ 9 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 10 | "skipLibCheck": true, /* Skip type checking of declaration files. */ 11 | "noUnusedLocals": true, 12 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 13 | }, 14 | "include": [ 15 | "src/**/*", 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /test/config-sample.yaml: -------------------------------------------------------------------------------- 1 | log_level: error 2 | redis: 3 | address: 192.168.65.2:6379 4 | api_key: '****' 5 | api_secret: '****' 6 | ws_url: 'wss://your.livekit.url' 7 | file_prefix: /out/output 8 | s3: 9 | access_key: '****' 10 | secret: '****' 11 | region: us-east-1 12 | bucket: mybucket 13 | room_name: egress-test 14 | room_only: false 15 | track_composite_only: false 16 | track_only: false 17 | file_only: false 18 | stream_only: false 19 | segments_only: false 20 | muting: false 21 | -------------------------------------------------------------------------------- /test/flags.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build integration 16 | 17 | package test 18 | 19 | import "github.com/livekit/egress/pkg/types" 20 | 21 | const ( 22 | runRoom = 0b1 << 0 23 | runWeb = 0b1 << 1 24 | runParticipant = 0b1 << 2 25 | runTrackComposite = 0b1 << 3 26 | runTrack = 0b1 << 4 27 | 28 | runAllRequests = 0b11111 29 | 30 | runFile = 0b1 << 31 31 | runStream = 0b1 << 30 32 | runSegments = 0b1 << 29 33 | runImages = 0b1 << 28 34 | runMulti = 0b1 << 27 35 | runEdge = 0b1 << 26 36 | 37 | runAllOutputs = 0b111111 << 26 38 | ) 39 | 40 | var runRequestType = map[types.RequestType]uint{ 41 | types.RequestTypeRoomComposite: runRoom, 42 | types.RequestTypeWeb: runWeb, 43 | types.RequestTypeParticipant: runParticipant, 44 | types.RequestTypeTrackComposite: runTrackComposite, 45 | types.RequestTypeTrack: runTrack, 46 | } 47 | 48 | func (r *Runner) updateFlagset() { 49 | switch { 50 | case r.RoomTestsOnly: 51 | r.shouldRun |= runRoom 52 | case r.ParticipantTestsOnly: 53 | r.shouldRun |= runParticipant 54 | case r.WebTestsOnly: 55 | r.shouldRun |= runWeb 56 | case r.TrackCompositeTestsOnly: 57 | r.shouldRun |= runTrackComposite 58 | case r.TrackTestsOnly: 59 | r.shouldRun |= runTrack 60 | default: 61 | r.shouldRun |= runAllRequests 62 | } 63 | 64 | switch { 65 | case r.FileTestsOnly: 66 | r.shouldRun |= runFile 67 | case r.StreamTestsOnly: 68 | r.shouldRun |= runStream 69 | case r.SegmentTestsOnly: 70 | r.shouldRun |= runSegments 71 | case r.ImageTestsOnly: 72 | r.shouldRun |= runImages 73 | case r.MultiTestsOnly: 74 | r.shouldRun |= runMulti 75 | case r.EdgeCasesOnly: 76 | r.shouldRun |= runEdge 77 | default: 78 | r.shouldRun |= runAllOutputs 79 | } 80 | } 81 | 82 | func (r *Runner) should(runFlag uint) bool { 83 | return r.shouldRun&runFlag > 0 84 | } 85 | -------------------------------------------------------------------------------- /test/images.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build integration 16 | 17 | package test 18 | 19 | import ( 20 | "fmt" 21 | "path" 22 | "testing" 23 | "time" 24 | 25 | "github.com/stretchr/testify/require" 26 | 27 | "github.com/livekit/egress/pkg/config" 28 | "github.com/livekit/egress/pkg/types" 29 | "github.com/livekit/protocol/livekit" 30 | ) 31 | 32 | func (r *Runner) testImages(t *testing.T) { 33 | if !r.should(runImages) { 34 | return 35 | } 36 | 37 | t.Run("Images", func(t *testing.T) { 38 | for _, test := range []*testCase{ 39 | 40 | // ---- Room Composite ----- 41 | 42 | { 43 | name: "RoomComposite", 44 | requestType: types.RequestTypeRoomComposite, 45 | publishOptions: publishOptions{ 46 | audioCodec: types.MimeTypeOpus, 47 | videoCodec: types.MimeTypeH264, 48 | layout: "speaker", 49 | }, 50 | encodingOptions: &livekit.EncodingOptions{ 51 | Width: 640, 52 | Height: 360, 53 | }, 54 | imageOptions: &imageOptions{ 55 | prefix: "r_{room_name}_{time}", 56 | suffix: livekit.ImageFileSuffix_IMAGE_SUFFIX_TIMESTAMP, 57 | }, 58 | }, 59 | 60 | // ---- Track Composite ---- 61 | 62 | { 63 | name: "TrackComposite/H264", 64 | requestType: types.RequestTypeTrackComposite, 65 | publishOptions: publishOptions{ 66 | audioCodec: types.MimeTypeOpus, 67 | videoCodec: types.MimeTypeH264, 68 | }, 69 | imageOptions: &imageOptions{ 70 | prefix: "tc_{publisher_identity}_h264", 71 | }, 72 | }, 73 | } { 74 | if !r.run(t, test, r.runImagesTest) { 75 | return 76 | } 77 | } 78 | }) 79 | } 80 | 81 | func (r *Runner) runImagesTest(t *testing.T, test *testCase) { 82 | req := r.build(test) 83 | 84 | egressID := r.startEgress(t, req) 85 | 86 | time.Sleep(time.Second * 10) 87 | if r.Dotfiles { 88 | r.createDotFile(t, egressID) 89 | } 90 | 91 | // stop 92 | time.Sleep(time.Second * 15) 93 | res := r.stopEgress(t, egressID) 94 | 95 | // get params 96 | p, err := config.GetValidatedPipelineConfig(r.ServiceConfig, req) 97 | require.NoError(t, err) 98 | 99 | r.verifyImages(t, p, res) 100 | } 101 | 102 | func (r *Runner) verifyImages(t *testing.T, p *config.PipelineConfig, res *livekit.EgressInfo) { 103 | // egress info 104 | require.Equal(t, res.Error == "", res.Status != livekit.EgressStatus_EGRESS_FAILED) 105 | require.NotZero(t, res.StartedAt) 106 | require.NotZero(t, res.EndedAt) 107 | 108 | // image info 109 | require.Len(t, res.GetImageResults(), 1) 110 | images := res.GetImageResults()[0] 111 | 112 | require.Greater(t, images.ImageCount, int64(0)) 113 | 114 | imageConfig := p.GetImageConfigs()[0] 115 | for i := range images.ImageCount { 116 | storagePath := fmt.Sprintf("%s_%05d%s", images.FilenamePrefix, i, imageConfig.ImageExtension) 117 | localPath := path.Join(r.FilePrefix, path.Base(storagePath)) 118 | download(t, imageConfig.StorageConfig, localPath, storagePath, true) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /test/integration_test.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build integration 16 | 17 | package test 18 | 19 | import ( 20 | "embed" 21 | "io/fs" 22 | "testing" 23 | 24 | "github.com/stretchr/testify/require" 25 | 26 | "github.com/livekit/egress/pkg/info" 27 | "github.com/livekit/egress/pkg/server" 28 | "github.com/livekit/protocol/redis" 29 | "github.com/livekit/psrpc" 30 | ) 31 | 32 | var ( 33 | //go:embed templates 34 | templateEmbedFs embed.FS 35 | ) 36 | 37 | func TestEgress(t *testing.T) { 38 | r := NewRunner(t) 39 | 40 | rfs, err := fs.Sub(templateEmbedFs, "templates") 41 | require.NoError(t, err) 42 | 43 | // rpc client and server 44 | rc, err := redis.GetRedisClient(r.Redis) 45 | require.NoError(t, err) 46 | bus := psrpc.NewRedisMessageBus(rc) 47 | 48 | ioClient, err := info.NewIOClient(&r.ServiceConfig.BaseConfig, bus) 49 | require.NoError(t, err) 50 | 51 | svc, err := server.NewServer(r.ServiceConfig, bus, ioClient) 52 | require.NoError(t, err) 53 | 54 | r.StartServer(t, svc, bus, rfs) 55 | r.RunTests(t) 56 | } 57 | -------------------------------------------------------------------------------- /test/ioserver.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build integration 16 | 17 | package test 18 | 19 | import ( 20 | "context" 21 | 22 | "google.golang.org/protobuf/types/known/emptypb" 23 | 24 | "github.com/livekit/protocol/livekit" 25 | "github.com/livekit/protocol/logger" 26 | "github.com/livekit/protocol/rpc" 27 | "github.com/livekit/psrpc" 28 | ) 29 | 30 | type ioTestServer struct { 31 | rpc.IOInfoServerImpl 32 | server rpc.IOInfoServer 33 | updates chan *livekit.EgressInfo 34 | } 35 | 36 | func newIOTestServer(bus psrpc.MessageBus, updates chan *livekit.EgressInfo) (*ioTestServer, error) { 37 | s := &ioTestServer{ 38 | updates: updates, 39 | } 40 | server, err := rpc.NewIOInfoServer(s, bus) 41 | if err != nil { 42 | return nil, err 43 | } 44 | s.server = server 45 | return s, nil 46 | } 47 | 48 | func (s *ioTestServer) CreateEgress(_ context.Context, info *livekit.EgressInfo) (*emptypb.Empty, error) { 49 | logger.Infow("egress created", "egressID", info.EgressId) 50 | return &emptypb.Empty{}, nil 51 | } 52 | 53 | func (s *ioTestServer) UpdateEgress(_ context.Context, info *livekit.EgressInfo) (*emptypb.Empty, error) { 54 | logger.Infow("egress updated", "egressID", info.EgressId, "status", info.Status) 55 | s.updates <- info 56 | return &emptypb.Empty{}, nil 57 | } 58 | 59 | func (s *ioTestServer) UpdateMetrics(_ context.Context, _ *rpc.UpdateMetricsRequest) (*emptypb.Empty, error) { 60 | return &emptypb.Empty{}, nil 61 | } 62 | -------------------------------------------------------------------------------- /test/multi.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build integration 16 | 17 | package test 18 | 19 | import ( 20 | "context" 21 | "testing" 22 | "time" 23 | 24 | "github.com/stretchr/testify/require" 25 | 26 | "github.com/livekit/egress/pkg/config" 27 | "github.com/livekit/egress/pkg/types" 28 | "github.com/livekit/protocol/livekit" 29 | ) 30 | 31 | func (r *Runner) testMulti(t *testing.T) { 32 | if !r.should(runMulti) { 33 | return 34 | } 35 | 36 | t.Run("Multi", func(t *testing.T) { 37 | for _, test := range []*testCase{ 38 | 39 | // ---- Room Composite ----- 40 | 41 | { 42 | name: "RoomComposite", 43 | requestType: types.RequestTypeRoomComposite, publishOptions: publishOptions{ 44 | audioCodec: types.MimeTypeOpus, 45 | videoCodec: types.MimeTypeVP8, 46 | }, 47 | fileOptions: &fileOptions{ 48 | filename: "rc_multiple_{time}", 49 | }, 50 | imageOptions: &imageOptions{ 51 | prefix: "rc_image", 52 | }, 53 | multi: true, 54 | }, 55 | 56 | // ---------- Web ---------- 57 | 58 | { 59 | name: "Web", 60 | requestType: types.RequestTypeWeb, 61 | fileOptions: &fileOptions{ 62 | filename: "web_multiple_{time}", 63 | }, 64 | segmentOptions: &segmentOptions{ 65 | prefix: "web_multiple_{time}", 66 | playlist: "web_multiple_{time}.m3u8", 67 | }, 68 | multi: true, 69 | }, 70 | 71 | // ------ Participant ------ 72 | 73 | { 74 | name: "ParticipantComposite", 75 | requestType: types.RequestTypeParticipant, publishOptions: publishOptions{ 76 | audioCodec: types.MimeTypeOpus, 77 | audioUnpublish: time.Second * 20, 78 | videoCodec: types.MimeTypeVP8, 79 | videoDelay: time.Second * 5, 80 | }, 81 | fileOptions: &fileOptions{ 82 | filename: "participant_multiple_{time}", 83 | }, 84 | streamOptions: &streamOptions{ 85 | outputType: types.OutputTypeRTMP, 86 | }, 87 | multi: true, 88 | }, 89 | 90 | // ---- Track Composite ---- 91 | 92 | { 93 | name: "TrackComposite", 94 | requestType: types.RequestTypeTrackComposite, publishOptions: publishOptions{ 95 | audioCodec: types.MimeTypeOpus, 96 | videoCodec: types.MimeTypeVP8, 97 | }, 98 | streamOptions: &streamOptions{ 99 | outputType: types.OutputTypeRTMP, 100 | }, 101 | segmentOptions: &segmentOptions{ 102 | prefix: "tc_multiple_{time}", 103 | playlist: "tc_multiple_{time}.m3u8", 104 | }, 105 | multi: true, 106 | }, 107 | } { 108 | if !r.run(t, test, r.runMultiTest) { 109 | return 110 | } 111 | } 112 | }) 113 | } 114 | 115 | func (r *Runner) runMultiTest(t *testing.T, test *testCase) { 116 | req := r.build(test) 117 | 118 | egressID := r.startEgress(t, req) 119 | time.Sleep(time.Second * 10) 120 | 121 | // get params 122 | p, err := config.GetValidatedPipelineConfig(r.ServiceConfig, req) 123 | require.NoError(t, err) 124 | 125 | if test.streamOptions != nil { 126 | _, err = r.client.UpdateStream(context.Background(), egressID, &livekit.UpdateStreamRequest{ 127 | EgressId: egressID, 128 | AddOutputUrls: []string{rtmpUrl3}, 129 | }) 130 | require.NoError(t, err) 131 | 132 | time.Sleep(time.Second * 10) 133 | r.verifyStreams(t, p, rtmpUrl3) 134 | r.checkStreamUpdate(t, egressID, map[string]livekit.StreamInfo_Status{ 135 | rtmpUrl3Redacted: livekit.StreamInfo_ACTIVE, 136 | }) 137 | time.Sleep(time.Second * 10) 138 | } else { 139 | time.Sleep(time.Second * 20) 140 | } 141 | 142 | res := r.stopEgress(t, egressID) 143 | if test.fileOptions != nil { 144 | r.verifyFile(t, p, res) 145 | } 146 | if test.segmentOptions != nil { 147 | r.verifySegments(t, p, test.segmentOptions.suffix, res, false) 148 | } 149 | if test.imageOptions != nil { 150 | r.verifyImages(t, p, res) 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /test/publish.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | //go:build integration 16 | 17 | package test 18 | 19 | import ( 20 | "testing" 21 | "time" 22 | 23 | "github.com/stretchr/testify/require" 24 | 25 | "github.com/livekit/egress/pkg/types" 26 | lksdk "github.com/livekit/server-sdk-go/v2" 27 | ) 28 | 29 | var ( 30 | samples = map[types.MimeType]string{ 31 | types.MimeTypeOpus: "/media-samples/SolLevante.ogg", 32 | types.MimeTypeH264: "/media-samples/SolLevante.h264", 33 | types.MimeTypeVP8: "/media-samples/SolLevante-vp8.ivf", 34 | types.MimeTypeVP9: "/media-samples/SolLevante-vp9.ivf", 35 | } 36 | 37 | frameDurations = map[types.MimeType]time.Duration{ 38 | types.MimeTypeH264: time.Microsecond * 41667, 39 | types.MimeTypeVP8: time.Microsecond * 41667, 40 | types.MimeTypeVP9: time.Microsecond * 41667, 41 | } 42 | ) 43 | 44 | func (r *Runner) publishSample(t *testing.T, codec types.MimeType, publishAfter, unpublishAfter time.Duration, withMuting bool) string { 45 | if codec == "" { 46 | return "" 47 | } 48 | 49 | trackID := make(chan string, 1) 50 | time.AfterFunc(publishAfter, func() { 51 | done := make(chan struct{}) 52 | unpublished := make(chan struct{}) 53 | 54 | pub := r.publish(t, r.room.LocalParticipant, codec, done) 55 | trackID <- pub.SID() 56 | 57 | if withMuting { 58 | go func() { 59 | muted := false 60 | time.Sleep(time.Second * 15) 61 | for { 62 | select { 63 | case <-unpublished: 64 | return 65 | case <-done: 66 | return 67 | default: 68 | pub.SetMuted(!muted) 69 | muted = !muted 70 | time.Sleep(time.Second * 10) 71 | } 72 | } 73 | }() 74 | } 75 | 76 | if unpublishAfter != 0 { 77 | time.AfterFunc(unpublishAfter-publishAfter, func() { 78 | select { 79 | case <-done: 80 | return 81 | default: 82 | close(unpublished) 83 | _ = r.room.LocalParticipant.UnpublishTrack(pub.SID()) 84 | } 85 | }) 86 | } 87 | }) 88 | 89 | if publishAfter == 0 { 90 | return <-trackID 91 | } else { 92 | return "TBD" 93 | } 94 | } 95 | 96 | func (r *Runner) publishSampleWithDisconnection(t *testing.T, codec types.MimeType) string { 97 | pub := r.publish(t, r.room.LocalParticipant, codec, make(chan struct{})) 98 | trackID := pub.SID() 99 | 100 | time.AfterFunc(time.Second*10, func() { 101 | pub.SimulateDisconnection(time.Second * 10) 102 | }) 103 | 104 | return trackID 105 | } 106 | 107 | func (r *Runner) publish(t *testing.T, p *lksdk.LocalParticipant, codec types.MimeType, done chan struct{}) *lksdk.LocalTrackPublication { 108 | filename := samples[codec] 109 | frameDuration := frameDurations[codec] 110 | 111 | var pub *lksdk.LocalTrackPublication 112 | opts := []lksdk.ReaderSampleProviderOption{ 113 | lksdk.ReaderTrackWithOnWriteComplete(func() { 114 | close(done) 115 | if pub != nil { 116 | _ = p.UnpublishTrack(pub.SID()) 117 | } 118 | }), 119 | } 120 | 121 | if frameDuration != 0 { 122 | opts = append(opts, lksdk.ReaderTrackWithFrameDuration(frameDuration)) 123 | } 124 | 125 | track, err := lksdk.NewLocalFileTrack(filename, opts...) 126 | require.NoError(t, err) 127 | 128 | pub, err = p.PublishTrack(track, &lksdk.TrackPublicationOptions{Name: filename}) 129 | require.NoError(t, err) 130 | 131 | trackID := pub.SID() 132 | t.Cleanup(func() { 133 | _ = p.UnpublishTrack(trackID) 134 | }) 135 | 136 | return pub 137 | } 138 | -------------------------------------------------------------------------------- /version/version.go: -------------------------------------------------------------------------------- 1 | // Copyright 2023 LiveKit, Inc. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | package version 16 | 17 | const Version = "1.9.0" 18 | --------------------------------------------------------------------------------