diff --git a/.github/workflows/libmoq.yml b/.github/workflows/libmoq.yml index f59c4404f..af9bd4aa0 100644 --- a/.github/workflows/libmoq.yml +++ b/.github/workflows/libmoq.yml @@ -108,3 +108,102 @@ jobs: RELEASE_TITLE: "libmoq v${{ steps.parse.outputs.version }}" RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }} run: .github/scripts/release.sh create artifacts + + # The OBS plugin (cpp/obs) statically links libmoq, so it ships with every + # libmoq release: rebuild it against the release we just published (no + # second cargo build, guaranteed-matching ABI) and cut a parallel + # obs-moq-v release. Runs after `release` so the libmoq archives + # the plugin's CMake fetches are already live. + obs-build: + name: OBS plugin (${{ matrix.target }}) + needs: release + runs-on: ${{ matrix.os }} + + # macOS + Windows only. These link the ffmpeg the obs-deps bundle ships + # (the same one OBS uses), so the binaries are portable. A Linux build + # would link nix/distro ffmpeg and not be portable, so Linux is + # build-from-source (see doc/bin/obs.md) and ships no prebuilt binary. + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-pc-windows-msvc + os: windows-latest + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Parse version + id: parse + shell: bash + run: .github/scripts/release.sh parse-version libmoq + + # Native Xcode / Visual Studio; libobs/Qt6/ffmpeg come from the obs-deps + # bundle and libmoq from the release we just published (--libmoq-release). + - name: Build and package + shell: bash + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ steps.parse.outputs.version }} + run: | + ./cpp/obs/build.sh --target "$TARGET" --libmoq-release "$VERSION" --output dist + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: obs-moq-${{ matrix.target }} + path: dist/* + + obs-release: + name: OBS plugin release + needs: obs-build + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Parse version + id: parse + run: .github/scripts/release.sh parse-version libmoq + + # Only the obs-moq archives: the libmoq-* artifacts from the build job + # belong to the libmoq release, not this one. + - name: Download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: artifacts + pattern: obs-moq-* + merge-multiple: true + + # release.sh prev-tag keys off the current tag, but the trigger here is a + # libmoq-v* tag, so it can't find the previous obs-moq tag. Take the + # newest existing obs-moq tag (excluding this version, for safe re-runs) + # so the release notes span only since the last obs-moq release. + - name: Find previous obs-moq tag + id: prev_tag + env: + VERSION: ${{ steps.parse.outputs.version }} + run: | + cur="obs-moq-v${VERSION}" + prev=$(git tag --list 'obs-moq-v*' --sort=-v:refname | grep -vx "$cur" | head -n1) + echo "tag=${prev}" >>"$GITHUB_OUTPUT" + + # Cut a parallel obs-moq-v release at this commit (gh creates + # the tag). + - name: Create or update release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: "obs-moq-v${{ steps.parse.outputs.version }}" + RELEASE_TITLE: "obs-moq v${{ steps.parse.outputs.version }}" + RELEASE_PREV_TAG: ${{ steps.prev_tag.outputs.tag }} + run: .github/scripts/release.sh create artifacts diff --git a/CLAUDE.md b/CLAUDE.md index a73d86332..9102162d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,6 +88,9 @@ Key architectural rule: The CDN/relay does not know anything about media. Anythi # CI mirrors them to moq-dev/moq-{swift,kotlin,go} # on each moq-ffi-v* tag. +/cpp/ # C/C++ consumers of libmoq + obs/ # OBS Studio plugin (CMake; links libmoq via MOQ_LOCAL). GPL-2.0-or-later. + /demo/ # Demos and test media boy/ # MoQ Boy demo (ROM hosting, orchestration justfile) relay/ # Relay server configs (relay.toml, root.toml, leaf*.toml) @@ -193,6 +196,7 @@ Changes in one area usually need matching updates elsewhere, including docs. If | `rs/moq-relay` config/behavior | `doc/bin/relay/` | | `rs/moq-cli` | `doc/bin/cli.md` | | `rs/moq-gst` | `doc/bin/gstreamer.md` | +| `rs/libmoq` C ABI (`moq.h`) | `cpp/obs/src`, `doc/bin/obs.md` | | `js/{watch,publish}` UI/API | `demo/web` if it consumes the API | ## Branch Targeting diff --git a/README.md b/README.md index ed87bee23..5e8b0fac3 100644 --- a/README.md +++ b/README.md @@ -135,3 +135,5 @@ Licensed under either: - Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or https://www.apache.org/licenses/LICENSE-2.0) - MIT license ([LICENSE-MIT](LICENSE-MIT) or https://opensource.org/licenses/MIT) + +**Exception:** the OBS plugin under [`cpp/obs/`](cpp/obs) is licensed under **GPL-2.0-or-later** (see [`cpp/obs/LICENSE`](cpp/obs/LICENSE)), because it links OBS Studio's `libobs`, which is GPL-2.0. This is a separately-distributable work; per GPLv2 its presence in this repository is mere aggregation and does not affect the MIT/Apache licensing of the rest of the project. `libmoq` and the other moq crates remain MIT/Apache. diff --git a/cpp/obs/.clang-format b/cpp/obs/.clang-format new file mode 100644 index 000000000..73c5b0faa --- /dev/null +++ b/cpp/obs/.clang-format @@ -0,0 +1,209 @@ +# please use clang-format version 16 or later + +Standard: c++17 +AccessModifierOffset: -8 +AlignAfterOpenBracket: Align +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: Left +AlignOperands: true +AlignTrailingComments: true +AllowAllArgumentsOnNextLine: false +AllowAllConstructorInitializersOnNextLine: false +AllowAllParametersOfDeclarationOnNextLine: false +AllowShortBlocksOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: Inline +AllowShortIfStatementsOnASingleLine: false +AllowShortLambdasOnASingleLine: Inline +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterDefinitionReturnType: None +AlwaysBreakAfterReturnType: None +AlwaysBreakBeforeMultilineStrings: false +AlwaysBreakTemplateDeclarations: false +BinPackArguments: true +BinPackParameters: true +BraceWrapping: + AfterClass: false + AfterControlStatement: false + AfterEnum: false + AfterFunction: true + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + AfterExternBlock: false + BeforeCatch: false + BeforeElse: false + IndentBraces: false + SplitEmptyFunction: true + SplitEmptyRecord: true + SplitEmptyNamespace: true +BreakBeforeBinaryOperators: None +BreakBeforeBraces: Custom +BreakBeforeTernaryOperators: true +BreakConstructorInitializers: BeforeColon +BreakStringLiterals: false # apparently unpredictable +ColumnLimit: 120 +CompactNamespaces: false +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 8 +ContinuationIndentWidth: 8 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +FixNamespaceComments: true +ForEachMacros: + - 'json_object_foreach' + - 'json_object_foreach_safe' + - 'json_array_foreach' + - 'HASH_ITER' +IncludeBlocks: Preserve +IndentCaseLabels: false +IndentPPDirectives: None +IndentWidth: 8 +IndentWrappedFunctionNames: false +KeepEmptyLinesAtTheStartOfBlocks: true +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +ObjCBinPackProtocolList: Auto +ObjCBlockIndentWidth: 8 +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true + +PenaltyBreakAssignment: 10 +PenaltyBreakBeforeFirstCallParameter: 30 +PenaltyBreakComment: 10 +PenaltyBreakFirstLessLess: 0 +PenaltyBreakString: 10 +PenaltyExcessCharacter: 100 +PenaltyReturnTypeOnItsOwnLine: 60 + +PointerAlignment: Right +ReflowComments: false +SkipMacroDefinitionBody: true +SortIncludes: false +SortUsingDeclarations: false +SpaceAfterCStyleCast: false +SpaceAfterLogicalNot: false +SpaceAfterTemplateKeyword: false +SpaceBeforeAssignmentOperators: true +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 1 +SpacesInAngles: false +SpacesInCStyleCastParentheses: false +SpacesInContainerLiterals: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +StatementMacros: + - 'Q_OBJECT' +TabWidth: 8 +TypenameMacros: + - 'DARRAY' +UseTab: ForContinuationAndIndentation +--- +Language: ObjC +AccessModifierOffset: 2 +AlignArrayOfStructures: Right +AlignConsecutiveAssignments: None +AlignConsecutiveBitFields: None +AlignConsecutiveDeclarations: None +AlignConsecutiveMacros: + Enabled: true + AcrossEmptyLines: false + AcrossComments: true +AllowShortBlocksOnASingleLine: Never +AllowShortEnumsOnASingleLine: false +AllowShortFunctionsOnASingleLine: Empty +AllowShortIfStatementsOnASingleLine: Never +AllowShortLambdasOnASingleLine: None +AttributeMacros: ['__unused', '__autoreleasing', '_Nonnull', '__bridge'] +BitFieldColonSpacing: Both +#BreakBeforeBraces: Webkit +BreakBeforeBraces: Custom +BraceWrapping: + AfterCaseLabel: false + AfterClass: true + AfterControlStatement: Never + AfterEnum: false + AfterFunction: true + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + AfterExternBlock: false + BeforeCatch: false + BeforeElse: false + BeforeLambdaBody: false + BeforeWhile: false + IndentBraces: false + SplitEmptyFunction: false + SplitEmptyRecord: false + SplitEmptyNamespace: true +BreakAfterAttributes: Never +BreakArrays: false +BreakBeforeConceptDeclarations: Allowed +BreakBeforeInlineASMColon: OnlyMultiline +BreakConstructorInitializers: AfterColon +BreakInheritanceList: AfterComma +ColumnLimit: 120 +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +EmptyLineAfterAccessModifier: Never +EmptyLineBeforeAccessModifier: LogicalBlock +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +IndentAccessModifiers: false +IndentCaseBlocks: false +IndentCaseLabels: true +IndentExternBlock: Indent +IndentGotoLabels: false +IndentRequiresClause: true +IndentWidth: 4 +IndentWrappedFunctionNames: true +InsertBraces: false +InsertNewlineAtEOF: true +KeepEmptyLinesAtTheStartOfBlocks: false +LambdaBodyIndentation: Signature +NamespaceIndentation: All +ObjCBinPackProtocolList: Auto +ObjCBlockIndentWidth: 4 +ObjCBreakBeforeNestedBlockParam: false +ObjCSpaceAfterProperty: true +ObjCSpaceBeforeProtocolList: true +PPIndentWidth: -1 +PackConstructorInitializers: NextLine +QualifierAlignment: Leave +ReferenceAlignment: Right +RemoveSemicolon: false +RequiresClausePosition: WithPreceding +RequiresExpressionIndentation: OuterScope +SeparateDefinitionBlocks: Leave +ShortNamespaceLines: 1 +SortIncludes: false +#SortUsingDeclarations: LexicographicNumeric +SortUsingDeclarations: true +SpaceAfterCStyleCast: true +SpaceAfterLogicalNot: false +SpaceAroundPointerQualifiers: Default +SpaceBeforeCaseColon: false +SpaceBeforeCpp11BracedList: true +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceBeforeSquareBrackets: false +SpaceInEmptyBlock: false +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInConditionalStatement: false +SpacesInLineCommentPrefix: + Minimum: 1 + Maximum: -1 +Standard: c++17 +TabWidth: 4 +UseTab: Never diff --git a/cpp/obs/.gersemirc b/cpp/obs/.gersemirc new file mode 100644 index 000000000..59c4a78d2 --- /dev/null +++ b/cpp/obs/.gersemirc @@ -0,0 +1,8 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/BlankSpruce/gersemi/master/gersemi/configuration.schema.json + +definitions: [] +line_length: 120 +indent: 2 +list_expansion: favour-inlining +unsafe: false +warn_about_unknown_commands: false diff --git a/cpp/obs/.gitignore b/cpp/obs/.gitignore new file mode 100644 index 000000000..85d45cad7 --- /dev/null +++ b/cpp/obs/.gitignore @@ -0,0 +1,11 @@ +# CMake build trees (per the platform presets) and CPack output. +build_macos/ +build_x64/ +build_x86_64/ +release/ + +# Downloaded OBS sources + prebuilt obs-deps/Qt6 (macOS/Windows buildspec). +.deps/ + +# Generated build counter. +cmake/.CMakeBuildNumber diff --git a/cpp/obs/CMakeLists.txt b/cpp/obs/CMakeLists.txt new file mode 100644 index 000000000..753113036 --- /dev/null +++ b/cpp/obs/CMakeLists.txt @@ -0,0 +1,134 @@ +cmake_minimum_required(VERSION 3.28) + +include("${CMAKE_CURRENT_SOURCE_DIR}/cmake/common/bootstrap.cmake" NO_POLICY_SCOPE) + +project(${_name} VERSION ${_version}) + +option(ENABLE_FRONTEND_API "Use obs-frontend-api for UI functionality" OFF) +option(ENABLE_QT "Use Qt functionality" OFF) + +include(compilerconfig) +include(defaults) +include(helpers) + +add_library(obs-moq MODULE) + +if(${BUILD_PLUGIN}) + find_package(libobs REQUIRED) + # FFmpeg dependency (used by the MoQ source to decode subscribed video). On + # macOS and Windows, link the ffmpeg the obs-deps bundle ships -- the same one + # OBS itself uses at runtime -- via the vendored FindFFmpeg finder (obs-deps + # ship ffmpeg with no pkg-config .pc files, so it searches the prefix). On + # Linux there's no obs-deps; pkg-config finds the nix/system ffmpeg. + if(WIN32 OR APPLE) + find_package(FFmpeg REQUIRED avcodec avutil swscale swresample) + target_link_libraries( + obs-moq + PRIVATE FFmpeg::avcodec FFmpeg::avutil FFmpeg::swscale FFmpeg::swresample + ) + else() + include(FindPkgConfig) + pkg_check_modules(FFMPEG REQUIRED libavcodec libavutil libswscale libswresample) + target_include_directories(obs-moq PRIVATE ${FFMPEG_INCLUDE_DIRS}) + target_link_directories(obs-moq PRIVATE ${FFMPEG_LIBRARY_DIRS}) + target_link_libraries(obs-moq PRIVATE ${FFMPEG_LIBRARIES}) + endif() +else() + find_package(FFmpeg REQUIRED avcodec avutil swscale swresample) + target_link_libraries(obs-moq PRIVATE FFmpeg::avcodec FFmpeg::avutil FFmpeg::swscale FFmpeg::swresample) +endif() + +target_link_libraries(obs-moq PRIVATE OBS::libobs) + +# Default to the in-tree moq checkout (this file lives at cpp/obs, so the repo +# root is two levels up). A consumer building cpp/obs in isolation can point +# MOQ_LOCAL elsewhere, or unset it to fall back to the published release. +set(MOQ_LOCAL "${CMAKE_CURRENT_SOURCE_DIR}/../.." CACHE PATH "Path to the moq repo (builds rs/libmoq in-tree)") + +if(MOQ_LOCAL AND EXISTS "${MOQ_LOCAL}/rs/libmoq/CMakeLists.txt") + add_subdirectory("${MOQ_LOCAL}/rs/libmoq" moq) + target_link_libraries(obs-moq PRIVATE moq) +else() + include(FetchContent) + FetchContent_Declare( + moq + URL + https://github.com/moq-dev/moq/releases/download/libmoq-v${MOQ_VERSION}/moq-${MOQ_VERSION}-${MOQ_TARGET}.${MOQ_ARCHIVE} + ) + FetchContent_MakeAvailable(moq) + + find_package(moq REQUIRED PATHS ${moq_SOURCE_DIR} NO_DEFAULT_PATH) + target_link_libraries(obs-moq PRIVATE moq::moq) +endif() + +# The obs-deps Qt6 build references the AGL framework transitively (via +# WrapOpenGL), but recent macOS SDKs ship no linkable AGL binary -- it exists +# only in the runtime dyld shared cache. Generate a stub whose install name +# points at the real framework so the link succeeds; dyld resolves AGL at load. +if( + (ENABLE_QT OR ENABLE_FRONTEND_API) + AND APPLE + AND NOT EXISTS "${CMAKE_OSX_SYSROOT}/System/Library/Frameworks/AGL.framework/Versions/A/AGL" +) + set(_agl_stub_dir "${CMAKE_BINARY_DIR}/agl-stub") + set(_agl_stub_lib "${_agl_stub_dir}/AGL.framework/AGL") + if(NOT EXISTS "${_agl_stub_lib}") + file(MAKE_DIRECTORY "${_agl_stub_dir}/AGL.framework") + set(_agl_arch_flags "") + foreach(_arch IN LISTS CMAKE_OSX_ARCHITECTURES) + list(APPEND _agl_arch_flags "-arch" "${_arch}") + endforeach() + execute_process( + COMMAND + xcrun clang -dynamiclib ${_agl_arch_flags} -install_name /System/Library/Frameworks/AGL.framework/Versions/A/AGL + -o "${_agl_stub_lib}" -x c /dev/null + RESULT_VARIABLE _agl_stub_result + ) + if(NOT _agl_stub_result EQUAL 0) + message(WARNING "Failed to build AGL stub (${_agl_stub_result}); Qt link may fail") + endif() + endif() + target_link_options(obs-moq PRIVATE "-F${_agl_stub_dir}") +endif() + +if(ENABLE_FRONTEND_API) + find_package(obs-frontend-api REQUIRED) + target_link_libraries(obs-moq PRIVATE OBS::obs-frontend-api) +endif() + +if(ENABLE_QT) + find_package(Qt6 COMPONENTS Widgets Core) + target_link_libraries(obs-moq PRIVATE Qt6::Core Qt6::Widgets) + target_compile_options( + obs-moq + PRIVATE $<$:-Wno-quoted-include-in-framework-header -Wno-comma> + ) + set_target_properties( + obs-moq + PROPERTIES AUTOMOC ON AUTOUIC ON AUTORCC ON + ) +endif() + +target_sources( + obs-moq + PRIVATE + src/obs-moq.cpp + src/moq-output.h + src/moq-service.h + src/moq-output.cpp + src/moq-service.cpp + src/moq-source.cpp + src/moq-source.h +) + +# The dock requires both the frontend API (to register it) and Qt (to build it). +if(ENABLE_FRONTEND_API AND ENABLE_QT) + target_sources(obs-moq PRIVATE src/moq-dock.cpp src/moq-dock.h) + target_compile_definitions(obs-moq PRIVATE MOQ_FRONTEND_ENABLED MOQ_VERSION_STRING="${MOQ_VERSION}") +endif() + +if(${BUILD_PLUGIN}) + set_target_properties_plugin(obs-moq PROPERTIES OUTPUT_NAME ${_name}) +else() + set_target_properties_obs(obs-moq PROPERTIES FOLDER plugins PREFIX "") +endif() diff --git a/cpp/obs/CMakePresets.json b/cpp/obs/CMakePresets.json new file mode 100644 index 000000000..c2eb5554e --- /dev/null +++ b/cpp/obs/CMakePresets.json @@ -0,0 +1,177 @@ +{ + "version": 8, + "cmakeMinimumRequired": { + "major": 3, + "minor": 28, + "patch": 0 + }, + "configurePresets": [ + { + "name": "template", + "hidden": true, + "cacheVariables": { + "ENABLE_FRONTEND_API": true, + "ENABLE_QT": true, + "CMAKE_EXPORT_COMPILE_COMMANDS": true, + "BUILD_PLUGIN": true, + "MOQ_VERSION": "0.2.14", + "MOQ_ARCHIVE": "tar.gz" + } + }, + { + "name": "macos", + "displayName": "macOS arm64", + "description": "Build for macOS 12.0+ (arm64)", + "inherits": [ + "template" + ], + "binaryDir": "${sourceDir}/build_macos", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Darwin" + }, + "generator": "Xcode", + "warnings": { + "dev": true, + "deprecated": true + }, + "cacheVariables": { + "CMAKE_OSX_DEPLOYMENT_TARGET": "12.0", + "CMAKE_OSX_ARCHITECTURES": "arm64", + "CODESIGN_IDENTITY": "$penv{CODESIGN_IDENT}", + "CODESIGN_TEAM": "$penv{CODESIGN_TEAM}", + "MOQ_TARGET": "aarch64-apple-darwin" + } + }, + { + "name": "macos-ci", + "inherits": [ + "macos" + ], + "displayName": "macOS arm64 CI build", + "description": "Build for macOS 12.0+ (arm64) for CI", + "generator": "Xcode", + "cacheVariables": { + "CMAKE_COMPILE_WARNING_AS_ERROR": true, + "ENABLE_CCACHE": true + } + }, + { + "name": "windows-x64", + "displayName": "Windows x64", + "description": "Build for Windows x64", + "inherits": [ + "template" + ], + "binaryDir": "${sourceDir}/build_x64", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Windows" + }, + "generator": "Visual Studio 17 2022", + "architecture": "x64,version=10.0.22621", + "warnings": { + "dev": true, + "deprecated": true + }, + "cacheVariables": { + "MOQ_TARGET": "x86_64-pc-windows-msvc", + "MOQ_ARCHIVE": "zip" + } + }, + { + "name": "windows-ci-x64", + "inherits": [ + "windows-x64" + ], + "displayName": "Windows x64 CI build", + "description": "Build for Windows x64 on CI", + "cacheVariables": { + "CMAKE_COMPILE_WARNING_AS_ERROR": true + } + }, + { + "name": "ubuntu-x86_64", + "displayName": "Ubuntu x86_64", + "description": "Build for Ubuntu x86_64", + "inherits": [ + "template" + ], + "binaryDir": "${sourceDir}/build_x86_64", + "condition": { + "type": "equals", + "lhs": "${hostSystemName}", + "rhs": "Linux" + }, + "generator": "Ninja", + "warnings": { + "dev": true, + "deprecated": true + }, + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_INSTALL_LIBDIR": "lib/CMAKE_SYSTEM_PROCESSOR-linux-gnu", + "MOQ_TARGET": "x86_64-unknown-linux-gnu" + } + }, + { + "name": "ubuntu-ci-x86_64", + "inherits": [ + "ubuntu-x86_64" + ], + "displayName": "Ubuntu x86_64 CI build", + "description": "Build for Ubuntu x86_64 on CI", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo", + "CMAKE_COMPILE_WARNING_AS_ERROR": true, + "ENABLE_CCACHE": true + } + } + ], + "buildPresets": [ + { + "name": "macos", + "configurePreset": "macos", + "displayName": "macOS arm64", + "description": "macOS build for arm64", + "configuration": "RelWithDebInfo" + }, + { + "name": "macos-ci", + "configurePreset": "macos-ci", + "displayName": "macOS arm64 CI", + "description": "macOS CI build for arm64", + "configuration": "RelWithDebInfo" + }, + { + "name": "windows-x64", + "configurePreset": "windows-x64", + "displayName": "Windows x64", + "description": "Windows build for x64", + "configuration": "RelWithDebInfo" + }, + { + "name": "windows-ci-x64", + "configurePreset": "windows-ci-x64", + "displayName": "Windows x64 CI", + "description": "Windows CI build for x64 (RelWithDebInfo configuration)", + "configuration": "RelWithDebInfo" + }, + { + "name": "ubuntu-x86_64", + "configurePreset": "ubuntu-x86_64", + "displayName": "Ubuntu x86_64", + "description": "Ubuntu build for x86_64", + "configuration": "RelWithDebInfo" + }, + { + "name": "ubuntu-ci-x86_64", + "configurePreset": "ubuntu-ci-x86_64", + "displayName": "Ubuntu x86_64 CI", + "description": "Ubuntu CI build for x86_64", + "configuration": "RelWithDebInfo" + } + ] +} diff --git a/cpp/obs/LICENSE b/cpp/obs/LICENSE new file mode 100644 index 000000000..1b8a5cdd2 --- /dev/null +++ b/cpp/obs/LICENSE @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + {description} + Copyright (C) {year} {fullname} + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + {signature of Ty Coon}, 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. \ No newline at end of file diff --git a/cpp/obs/README.md b/cpp/obs/README.md new file mode 100644 index 000000000..2c16160fe --- /dev/null +++ b/cpp/obs/README.md @@ -0,0 +1,22 @@ +# obs-moq + +An OBS Studio plugin for publishing to and subscribing from MoQ relays. + +It loads into a stock OBS Studio install (no OBS source build required) and links +`libmoq`, built from the in-tree [`rs/libmoq`](../../rs/libmoq) crate. + +Build instructions for each platform live in [`doc/bin/obs.md`](../../doc/bin/obs.md). +In short, from the repo root: + +```bash +# Linux: the dev shell provides libobs/Qt6/ffmpeg +nix develop +just obs build + +# macOS / Windows: needs Xcode / Visual Studio 2022; obs-deps download via buildspec.json +just obs setup +just obs build +``` + +Licensed under GPL-2.0-or-later (see [LICENSE](LICENSE)), separate from the rest of the +repository, because it links OBS. diff --git a/cpp/obs/build.sh b/cpp/obs/build.sh new file mode 100755 index 000000000..4aac4c214 --- /dev/null +++ b/cpp/obs/build.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build and package the obs-moq plugin for release. +# Usage: ./build.sh [--target TARGET] [--version VERSION] [--output DIR] +# +# The required toolchain must already be on PATH; this script only drives +# CMake. Per platform: +# Linux - run inside `nix develop` (provides cmake/ninja/obs-studio/qt6/ffmpeg) +# macOS - full Xcode, run OUTSIDE nix (libobs/Qt6/ffmpeg all come from the +# obs-deps bundle downloaded by buildspec.json at configure time) +# Windows - Visual Studio 2022; run from Git Bash with cmake on PATH +# (libobs/Qt6 downloaded by buildspec.json) +# +# Produces $OUTPUT_DIR/obs-moq-$VERSION-$TARGET.{tar.gz,zip}. The archive is +# unsigned; macOS Gatekeeper / Windows SmartScreen will warn on first load. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +TARGET="" +VERSION="" +OUTPUT_DIR="dist" +MOQ_RELEASE="" + +while [[ $# -gt 0 ]]; do + case $1 in + --target) + TARGET="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + --output) + OUTPUT_DIR="$2" + shift 2 + ;; + --libmoq-release) + # Link a published libmoq release of this version instead of + # building rs/libmoq from source. CMake fetches the matching + # moq-- archive from the GitHub release and the + # plugin is versioned to match. Used by CI on a libmoq-v* tag. + MOQ_RELEASE="$2" + shift 2 + ;; + -h | --help) + echo "Usage: $0 [--target TARGET] [--version VERSION] [--output DIR] [--libmoq-release VERSION]" + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +# In libmoq-release mode the plugin version tracks the libmoq version. +if [[ -n "$MOQ_RELEASE" ]]; then + VERSION="$MOQ_RELEASE" +fi + +if [[ -z "$TARGET" ]]; then + TARGET=$(cc -dumpmachine 2>/dev/null || echo unknown) + echo "Detected target: $TARGET" +fi + +# Default the version from buildspec.json's top-level "version" (the nested +# dependency entries also have "version" keys, hence the leading-indent anchor). +if [[ -z "$VERSION" ]]; then + VERSION=$(grep -E '^[[:space:]]{4}"version"' "$SCRIPT_DIR/buildspec.json" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/') + echo "Detected version: $VERSION" +fi + +# Map the target triple to a CMake preset and build tree. +case "$TARGET" in + *-linux-*) + PRESET="ubuntu-x86_64" + BUILD_DIR="$SCRIPT_DIR/build_x86_64" + KIND="unix" + ;; + *-apple-darwin) + PRESET="macos" + BUILD_DIR="$SCRIPT_DIR/build_macos" + KIND="macos" + ;; + *-windows-*) + PRESET="windows-x64" + BUILD_DIR="$SCRIPT_DIR/build_x64" + KIND="windows" + ;; + *) + echo "Unsupported target: $TARGET" >&2 + exit 1 + ;; +esac + +# Resolve the output dir to an absolute path before we cd into the plugin +# directory (cmake --preset reads CMakePresets.json from the current dir). +mkdir -p "$OUTPUT_DIR" +OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" +cd "$SCRIPT_DIR" + +echo "Building obs-moq $VERSION for $TARGET (preset: $PRESET)..." +CONFIGURE_ARGS=() +# Stamp the plugin's compiled-in version (project version, macOS Info.plist, +# Windows resource) to match what we're building, not buildspec.json's 0.0.1. +if [[ -n "$VERSION" ]]; then + CONFIGURE_ARGS+=("-DPLUGIN_VERSION_OVERRIDE=$VERSION") +fi +if [[ -n "$MOQ_RELEASE" ]]; then + # Empty MOQ_LOCAL forces CMake's release-download branch; MOQ_VERSION and + # MOQ_TARGET steer it at this target's archive (the presets hard-code an + # x86_64/stale default). MOQ_ARCHIVE is correct per preset already. + echo "Linking libmoq release v$MOQ_RELEASE ($TARGET)" + CONFIGURE_ARGS+=(-DMOQ_LOCAL= "-DMOQ_VERSION=$MOQ_RELEASE" "-DMOQ_TARGET=$TARGET") +fi +cmake --preset "$PRESET" ${CONFIGURE_ARGS[@]+"${CONFIGURE_ARGS[@]}"} +cmake --build --preset "$PRESET" + +NAME="obs-moq-${VERSION}-${TARGET}" +STAGE="$OUTPUT_DIR/$NAME" +rm -rf "$STAGE" +mkdir -p "$OUTPUT_DIR" + +if [[ "$KIND" == "macos" ]]; then + # Self-contained loadable bundle; drop into the OBS plugins directory. + PLUGIN=$(find "$BUILD_DIR" -name 'obs-moq.plugin' -maxdepth 4 -print -quit) + [[ -n "$PLUGIN" ]] || { + echo "obs-moq.plugin not found under $BUILD_DIR" >&2 + exit 1 + } + mkdir -p "$STAGE" + cp -R "$PLUGIN" "$STAGE/" +else + # OBS portable-plugin layout: extract into your OBS plugins directory. + LIB=$(find "$BUILD_DIR" \( -name 'obs-moq.so' -o -name 'obs-moq.dll' \) -print -quit) + [[ -n "$LIB" ]] || { + echo "obs-moq.{so,dll} not found under $BUILD_DIR" >&2 + exit 1 + } + mkdir -p "$STAGE/obs-moq/bin/64bit" + cp "$LIB" "$STAGE/obs-moq/bin/64bit/" + cp -R "$SCRIPT_DIR/data" "$STAGE/obs-moq/" +fi + +cp "$SCRIPT_DIR/LICENSE" "$STAGE/" +cp "$SCRIPT_DIR/README.md" "$STAGE/" + +# Archive with CMake's tar so we don't depend on zip/gtar being present +# (notably on the Windows runner). tar.gz on unix, zip on macOS/Windows. +( + cd "$OUTPUT_DIR" + if [[ "$KIND" == "unix" ]]; then + ARCHIVE="$NAME.tar.gz" + cmake -E tar czf "$ARCHIVE" "$NAME" + else + ARCHIVE="$NAME.zip" + cmake -E tar cf "$ARCHIVE" --format=zip "$NAME" + fi + rm -rf "$NAME" + echo "Created: $OUTPUT_DIR/$ARCHIVE" +) diff --git a/cpp/obs/buildspec.json b/cpp/obs/buildspec.json new file mode 100644 index 000000000..f4e18bad5 --- /dev/null +++ b/cpp/obs/buildspec.json @@ -0,0 +1,45 @@ +{ + "dependencies": { + "obs-studio": { + "version": "31.1.1", + "baseUrl": "https://github.com/obsproject/obs-studio/archive/refs/tags", + "label": "OBS sources", + "hashes": { + "macos": "39751f067bacc13d44b116c5138491b5f1391f91516d3d590d874edd21292291", + "windows-x64": "2c8427c10b55ac6d68008df2e9a3e82f4647aaad18f105e30d4713c2de678ccf" + } + }, + "prebuilt": { + "version": "2025-07-11", + "baseUrl": "https://github.com/obsproject/obs-deps/releases/download", + "label": "Pre-Built obs-deps", + "hashes": { + "macos": "495687e63383d1a287684b6e2e9bfe246bb8f156fe265926afb1a325af1edd2a", + "windows-x64": "c8c642c1070dc31ce9a0f1e4cef5bb992f4bff4882255788b5da12129e85caa7" + } + }, + "qt6": { + "version": "2025-07-11", + "baseUrl": "https://github.com/obsproject/obs-deps/releases/download", + "label": "Pre-Built Qt6", + "hashes": { + "macos": "d3f5f04b6ea486e032530bdf0187cbda9a54e0a49621a4c8ba984c5023998867", + "windows-x64": "0e76bf0555dd5382838850b748d3dcfab44a1e1058441309ab54e1a65b156d0a" + }, + "debugSymbols": { + "windows-x64": "11b7be92cf66a273299b8f3515c07a5cfb61614b59a4e67f7fc5ecba5e2bdf21" + } + } + }, + "platformConfig": { + "macos": { + "bundleId": "dev.montevideotech.obs-moq" + } + }, + "name": "obs-moq", + "displayName": "MOQ for OBS", + "version": "0.0.1", + "author": "Montevideo Tech", + "website": "https://montevideotech.dev", + "email": "emil@qualabs.com" +} \ No newline at end of file diff --git a/cpp/obs/cmake/common/FindFFmpeg.cmake b/cpp/obs/cmake/common/FindFFmpeg.cmake new file mode 100644 index 000000000..d5200ced8 --- /dev/null +++ b/cpp/obs/cmake/common/FindFFmpeg.cmake @@ -0,0 +1,366 @@ +#[=======================================================================[.rst +FindFFmpeg +---------- + +FindModule for FFmpeg and associated libraries + +.. versionchanged:: 3.0 + Updated FindModule to CMake standards + +Components +^^^^^^^^^^ + +.. versionadded:: 1.0 + +This module contains provides several components: + +``avcodec`` +``avdevice`` +``avfilter`` +``avformat`` +``avutil`` +``postproc`` +``swscale`` +``swresample`` + +Import targets exist for each component. + +Imported Targets +^^^^^^^^^^^^^^^^ + +.. versionadded:: 2.0 + +This module defines the :prop_tgt:`IMPORTED` targets: + +``FFmpeg::avcodec`` + AVcodec component + +``FFmpeg::avdevice`` + AVdevice component + +``FFmpeg::avfilter`` + AVfilter component + +``FFmpeg::avformat`` + AVformat component + +``FFmpeg::avutil`` + AVutil component + +``FFmpeg::postproc`` + postproc component + +``FFmpeg::swscale`` + SWscale component + +``FFmpeg::swresample`` + SWresample component + +Result Variables +^^^^^^^^^^^^^^^^ + +This module sets the following variables: + +``FFmpeg_FOUND`` + True, if all required components and the core library were found. +``FFmpeg_VERSION`` + Detected version of found FFmpeg libraries. +``FFmpeg_INCLUDE_DIRS`` + Include directories needed for FFmpeg. +``FFmpeg_LIBRARIES`` + Libraries needed to link to FFmpeg. +``FFmpeg_DEFINITIONS`` + Compiler flags required for FFmpeg. + +``FFmpeg__VERSION`` + Detected version of found FFmpeg component library. +``FFmpeg__INCLUDE_DIRS`` + Include directories needed for FFmpeg component. +``FFmpeg__LIBRARIES`` + Libraries needed to link to FFmpeg component. +``FFmpeg__DEFINITIONS`` + Compiler flags required for FFmpeg component. + +Cache variables +^^^^^^^^^^^^^^^ + +The following cache variables may also be set: + +``FFmpeg__LIBRARY`` + Path to the library component of FFmpeg. +``FFmpeg__INCLUDE_DIR`` + Directory containing ``.h``. + +#]=======================================================================] + +include(FindPackageHandleStandardArgs) + +set( + _DEFAULT_COMPONENTS + avcodec + avdevice + avformat + avfilter + avresample + avutil + postproc + swscale + swresample +) + +set(component_avcodec libavcodec avcodec avcodec.h) +set(component_avdevice libavdevice avdevice avdevice.h) +set(component_avformat libavformat avformat avformat.h) +set(component_avfilter libavfilter avfilter avfilter.h) +set(component_avresample libavresample avresample avresample.h) +set(component_avutil libavutil avutil avutil.h) +set(component_postproc libpostproc postproc postprocess.h) +set(component_swscale libswscale swscale swscale.h) +set(component_swresample libswresample swresample swresample.h) + +if(NOT FFmpeg_FIND_COMPONENTS) + set(FFmpeg_FIND_COMPONENTS ${_DEFAULT_COMPONENTS}) +endif() + +# FFmpeg_find_component: Find and set up requested FFmpeg component +macro(FFmpeg_find_component component) + list(GET component_${component} 0 component_libname) + list(GET component_${component} 1 component_name) + list(GET component_${component} 2 component_header) + + if(NOT CMAKE_HOST_SYSTEM_NAME MATCHES "Windows") + find_package(PkgConfig QUIET) + if(PKG_CONFIG_FOUND) + pkg_search_module(PC_FFmpeg_${component} QUIET ${component_libname}) + endif() + endif() + + find_path( + FFmpeg_${component}_INCLUDE_DIR + NAMES ${component_libname}/${component_header} ${component_libname}/version.h + HINTS ${PC_FFmpeg_${component}_INCLUDE_DIRS} + PATHS /usr/include /usr/local/include + DOC "FFmpeg component ${component_name} include directory" + ) + + ffmpeg_check_version() + + if(CMAKE_HOST_SYSTEM_NAME MATCHES "Windows") + find_library( + FFmpeg_${component}_IMPLIB + NAMES ${component_libname} ${component_name} + DOC "FFmpeg component ${component_name} import library location" + ) + + ffmpeg_find_dll() + else() + find_library( + FFmpeg_${component}_LIBRARY + NAMES ${component_libname} ${component_name} + HINTS ${PC_FFmpeg_${component}_LIBRARY_DIRS} + PATHS /usr/lib /usr/local/lib + DOC "FFmpeg component ${component_name} location" + ) + endif() + + if(FFmpeg_${component}_LIBRARY AND FFmpeg_${component}_INCLUDE_DIR) + set(FFmpeg_${component}_FOUND TRUE) + set(FFmpeg_${component}_LIBRARIES ${${_library_var}}) + set(FFmpeg_${component}_INCLUDE_DIRS ${FFmpeg_${component}_INCLUDE_DIR}) + set(FFmpeg_${component}_DEFINITIONS ${PC_FFmpeg_${component}_CFLAGS_OTHER}) + mark_as_advanced(FFmpeg_${component}_LIBRARY FFmpeg_${component}_INCLUDE_DIR FFmpeg_${component}_IMPLIB) + endif() +endmacro() + +# FFmpeg_find_dll: Macro to find DLL for corresponding import library +macro(FFmpeg_find_dll) + cmake_path(GET FFmpeg_${component}_IMPLIB PARENT_PATH _implib_path) + cmake_path(SET _bin_path NORMALIZE "${_implib_path}/../bin") + + string(REGEX REPLACE "([0-9]+)\\.[0-9]+\\.[0-9]+" "\\1" _dll_version "${FFmpeg_${component}_VERSION}") + + find_program( + FFmpeg_${component}_LIBRARY + NAMES ${component_name}-${_dll_version}.dll + HINTS ${_implib_path} ${_bin_path} + DOC "FFmpeg component ${component_name} DLL location" + ) + + if(NOT FFmpeg_${component}_LIBRARY) + set(FFmpeg_${component}_LIBRARY "${FFmpeg_${component}_IMPLIB}") + endif() + + unset(_implib_path) + unset(_bin_path) + unset(_dll_version) +endmacro() + +# FFmpeg_check_version: Macro to help extract version number from FFmpeg headers +macro(FFmpeg_check_version) + if(PC_FFmpeg_${component}_VERSION) + set(FFmpeg_${component}_VERSION ${PC_FFmpeg_${component}_VERSION}) + elseif(EXISTS "${FFmpeg_${component}_INCLUDE_DIR}/${component_libname}/version.h") + if(EXISTS "${FFmpeg_${component}_INCLUDE_DIR}/${component_libname}/version_major.h") + file( + STRINGS + "${FFmpeg_${component}_INCLUDE_DIR}/${component_libname}/version_major.h" + _version_string + REGEX "^.*VERSION_MAJOR[ \t]+[0-9]+[ \t]*$" + ) + string(REGEX REPLACE ".*VERSION_MAJOR[ \t]+([0-9]+).*" "\\1" _version_major "${_version_string}") + + file( + STRINGS + "${FFmpeg_${component}_INCLUDE_DIR}/${component_libname}/version.h" + _version_string + REGEX "^.*VERSION_(MINOR|MICRO)[ \t]+[0-9]+[ \t]*$" + ) + string(REGEX REPLACE ".*VERSION_MINOR[ \t]+([0-9]+).*" "\\1" _version_minor "${_version_string}") + string(REGEX REPLACE ".*VERSION_MICRO[ \t]+([0-9]+).*" "\\1" _version_patch "${_version_string}") + else() + file( + STRINGS + "${FFmpeg_${component}_INCLUDE_DIR}/${component_libname}/version.h" + _version_string + REGEX "^.*VERSION_(MAJOR|MINOR|MICRO)[ \t]+[0-9]+[ \t]*$" + ) + string(REGEX REPLACE ".*VERSION_MAJOR[ \t]+([0-9]+).*" "\\1" _version_major "${_version_string}") + string(REGEX REPLACE ".*VERSION_MINOR[ \t]+([0-9]+).*" "\\1" _version_minor "${_version_string}") + string(REGEX REPLACE ".*VERSION_MICRO[ \t]+([0-9]+).*" "\\1" _version_patch "${_version_string}") + endif() + + set(FFmpeg_${component}_VERSION "${_version_major}.${_version_minor}.${_version_patch}") + unset(_version_major) + unset(_version_minor) + unset(_version_patch) + else() + if(NOT FFmpeg_FIND_QUIETLY) + message(AUTHOR_WARNING "Failed to find ${component_name} version.") + endif() + set(FFmpeg_${component}_VERSION 0.0.0) + endif() +endmacro() + +# FFmpeg_set_soname: Set SONAME property on imported library targets +macro(FFmpeg_set_soname) + if(CMAKE_HOST_SYSTEM_NAME MATCHES "Darwin") + execute_process( + COMMAND sh -c "otool -D '${FFmpeg_${component}_LIBRARY}' | grep -v '${FFmpeg_${component}_LIBRARY}'" + OUTPUT_VARIABLE _output + RESULT_VARIABLE _result + ) + + if(_result EQUAL 0 AND _output MATCHES "^@rpath/") + set_property(TARGET FFmpeg::${component} PROPERTY IMPORTED_SONAME "${_output}") + endif() + elseif(CMAKE_HOST_SYSTEM_NAME MATCHES "Linux|FreeBSD") + execute_process( + COMMAND sh -c "objdump -p '${FFmpeg_${component}_LIBRARY}' | grep SONAME" + OUTPUT_VARIABLE _output + RESULT_VARIABLE _result + ) + + if(_result EQUAL 0) + string(REGEX REPLACE "[ \t]+SONAME[ \t]+([^ \t]+)" "\\1" _soname "${_output}") + set_property(TARGET FFmpeg::${component} PROPERTY IMPORTED_SONAME "${_soname}") + unset(_soname) + endif() + endif() + unset(_output) + unset(_result) +endmacro() + +foreach(component IN LISTS FFmpeg_FIND_COMPONENTS) + if(NOT component IN_LIST _DEFAULT_COMPONENTS) + message(FATAL_ERROR "Unknown FFmpeg component specified: ${component}.") + endif() + + if(NOT FFmpeg_${component}_FOUND) + ffmpeg_find_component(${component}) + endif() + + if(FFmpeg_${component}_FOUND) + list(APPEND FFmpeg_LIBRARIES ${FFmpeg_${component}_LIBRARY}) + list(APPEND FFmpeg_DEFINITIONS ${FFmpeg_${component}_DEFINITIONS}) + list(APPEND FFmpeg_INCLUDE_DIRS ${FFmpeg_${component}_INCLUDE_DIR}) + endif() +endforeach() + +if(NOT FFmpeg_avutil_FOUND) + ffmpeg_find_component(avutil) +endif() + +if(EXISTS "${FFmpeg_avutil_INCLUDE_DIR}/libavutil/ffversion.h") + file( + STRINGS + "${FFmpeg_avutil_INCLUDE_DIR}/libavutil/ffversion.h" + _version_string + REGEX "^.*FFMPEG_VERSION[ \t]+\"n?[0-9a-z\\~+.-]+\"[ \t]*$" + ) + string(REGEX REPLACE ".*FFMPEG_VERSION[ \t]+\"n?([0-9]+\\.[0-9]).*\".*" "\\1" FFmpeg_VERSION "${_version_string}") +endif() + +list(REMOVE_DUPLICATES FFmpeg_INCLUDE_DIRS) +list(REMOVE_DUPLICATES FFmpeg_LIBRARIES) +list(REMOVE_DUPLICATES FFmpeg_DEFINITIONS) + +if(CMAKE_HOST_SYSTEM_NAME MATCHES "Darwin|Windows") + set(FFmpeg_ERROR_REASON "Ensure that obs-deps is provided as part of CMAKE_PREFIX_PATH.") +elseif(CMAKE_HOST_SYSTEM_NAME MATCHES "Linux|FreeBSD") + set(FFmpeg_ERROR_REASON "Ensure that required FFmpeg libraries are installed on the system.") +endif() + +find_package_handle_standard_args( + FFmpeg + REQUIRED_VARS FFmpeg_LIBRARIES FFmpeg_INCLUDE_DIRS + VERSION_VAR FFmpeg_VERSION + HANDLE_COMPONENTS + REASON_FAILURE_MESSAGE "${FFmpeg_ERROR_REASON}" +) + +if(FFmpeg_FOUND AND NOT TARGET FFmpeg::FFmpeg) + add_library(FFmpeg::FFmpeg INTERFACE IMPORTED) +endif() + +foreach(component IN LISTS FFmpeg_FIND_COMPONENTS) + if(FFmpeg_${component}_FOUND AND NOT TARGET FFmpeg::${component}) + if(IS_ABSOLUTE "${FFmpeg_${component}_LIBRARY}") + if(DEFINED FFmpeg_${component}_IMPLIB) + if(FFmpeg_${component}_IMPLIB STREQUAL FFmpeg_${component}_LIBRARY) + add_library(FFmpeg::${component} STATIC IMPORTED) + else() + add_library(FFmpeg::${component} SHARED IMPORTED) + set_property(TARGET FFmpeg::${component} PROPERTY IMPORTED_IMPLIB "${FFmpeg_${component}_IMPLIB}") + endif() + else() + add_library(FFmpeg::${component} UNKNOWN IMPORTED) + ffmpeg_set_soname() + endif() + + set_property(TARGET FFmpeg::${component} PROPERTY IMPORTED_LOCATION "${FFmpeg_${component}_LIBRARY}") + else() + add_library(FFmpeg::${component} INTERFACE IMPORTED) + set_property(TARGET FFmpeg::${component} PROPERTY IMPORTED_LIBNAME "${FFmpeg_${component}_LIBRARY}") + endif() + set_target_properties( + FFmpeg::${component} + PROPERTIES + INTERFACE_COMPILE_OPTIONS "${PC_FFmpeg_${component}_CFLAGS_OTHER}" + INTERFACE_INCLUDE_DIRECTORIES "${FFmpeg_${component}_INCLUDE_DIR}" + VERSION ${FFmpeg_${component}_VERSION} + ) + + get_target_property(_ffmpeg_interface_libraries FFmpeg::FFmpeg INTERFACE_LINK_LIBRARIES) + if(NOT FFmpeg::${component} IN_LIST _ffmpeg_interface_libraries) + set_property(TARGET FFmpeg::FFmpeg APPEND PROPERTY INTERFACE_LINK_LIBRARIES FFmpeg::${component}) + endif() + endif() +endforeach() + +include(FeatureSummary) +set_package_properties( + FFmpeg + PROPERTIES + URL "https://www.ffmpeg.org" + DESCRIPTION "A complete, cross-platform solution to record, convert and stream audio and video." +) diff --git a/cpp/obs/cmake/common/bootstrap.cmake b/cpp/obs/cmake/common/bootstrap.cmake new file mode 100644 index 000000000..8d4db05f3 --- /dev/null +++ b/cpp/obs/cmake/common/bootstrap.cmake @@ -0,0 +1,97 @@ +# Plugin bootstrap module + +include_guard(GLOBAL) + +# Map fallback configurations for optimized build configurations +# gersemi: off +set( + CMAKE_MAP_IMPORTED_CONFIG_RELWITHDEBINFO + RelWithDebInfo + Release + MinSizeRel + None + "" +) +set( + CMAKE_MAP_IMPORTED_CONFIG_MINSIZEREL + MinSizeRel + Release + RelWithDebInfo + None + "" +) +set( + CMAKE_MAP_IMPORTED_CONFIG_RELEASE + Release + RelWithDebInfo + MinSizeRel + None + "" +) +# gersemi: on + +# Prohibit in-source builds +if("${CMAKE_CURRENT_BINARY_DIR}" STREQUAL "${CMAKE_CURRENT_SOURCE_DIR}") + message( + FATAL_ERROR + "In-source builds are not supported. " + "Specify a build directory via 'cmake -S -B ' instead." + ) + file(REMOVE_RECURSE "${CMAKE_CURRENT_SOURCE_DIR}/CMakeCache.txt" "${CMAKE_CURRENT_SOURCE_DIR}/CMakeFiles") +endif() + +# Add common module directories to default search path +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/common") + +file(READ "${CMAKE_CURRENT_SOURCE_DIR}/buildspec.json" buildspec) + +string(JSON _name GET ${buildspec} name) +string(JSON _website GET ${buildspec} website) +string(JSON _author GET ${buildspec} author) +string(JSON _email GET ${buildspec} email) +string(JSON _version GET ${buildspec} version) +string(JSON _bundleId GET ${buildspec} platformConfig macos bundleId) + +# Release CI builds the plugin against a specific libmoq release and versions it +# to match (see cpp/obs/build.sh --libmoq-release), so allow overriding the +# buildspec version. Everything below derives PLUGIN_VERSION_* from _version. +if(DEFINED PLUGIN_VERSION_OVERRIDE AND NOT PLUGIN_VERSION_OVERRIDE STREQUAL "") + set(_version "${PLUGIN_VERSION_OVERRIDE}") +endif() + +set(PLUGIN_AUTHOR ${_author}) +set(PLUGIN_WEBSITE ${_website}) +set(PLUGIN_EMAIL ${_email}) +set(PLUGIN_VERSION ${_version}) +set(MACOS_BUNDLEID ${_bundleId}) + +string(REPLACE "." ";" _version_canonical "${_version}") +list(GET _version_canonical 0 PLUGIN_VERSION_MAJOR) +list(GET _version_canonical 1 PLUGIN_VERSION_MINOR) +list(GET _version_canonical 2 PLUGIN_VERSION_PATCH) +unset(_version_canonical) + +include(buildnumber) +include(osconfig) + +# Allow selection of common build types via UI +if(NOT CMAKE_GENERATOR MATCHES "(Xcode|Visual Studio .+)") + if(NOT CMAKE_BUILD_TYPE) + set( + CMAKE_BUILD_TYPE + "RelWithDebInfo" + CACHE STRING + "OBS build type [Release, RelWithDebInfo, Debug, MinSizeRel]" + FORCE + ) + set_property( + CACHE CMAKE_BUILD_TYPE + PROPERTY STRINGS Release RelWithDebInfo Debug MinSizeRel + ) + endif() +endif() + +# Disable exports automatically going into the CMake package registry +set(CMAKE_EXPORT_PACKAGE_REGISTRY FALSE) +# Enable default inclusion of targets' source and binary directory +set(CMAKE_INCLUDE_CURRENT_DIR TRUE) diff --git a/cpp/obs/cmake/common/buildnumber.cmake b/cpp/obs/cmake/common/buildnumber.cmake new file mode 100644 index 000000000..cc904496b --- /dev/null +++ b/cpp/obs/cmake/common/buildnumber.cmake @@ -0,0 +1,33 @@ +# CMake build number module + +include_guard(GLOBAL) + +# Define build number cache file +set( + _BUILD_NUMBER_CACHE + "${CMAKE_CURRENT_SOURCE_DIR}/cmake/.CMakeBuildNumber" + CACHE INTERNAL + "OBS build number cache file" +) + +# Read build number from cache file or manual override +if(NOT DEFINED PLUGIN_BUILD_NUMBER) + if(EXISTS "${_BUILD_NUMBER_CACHE}") + file(READ "${_BUILD_NUMBER_CACHE}" PLUGIN_BUILD_NUMBER) + math(EXPR PLUGIN_BUILD_NUMBER "${PLUGIN_BUILD_NUMBER}+1") + else() + if("$ENV{CI}") + if("$ENV{GITHUB_RUN_ID}") + set(PLUGIN_BUILD_NUMBER "$ENV{GITHUB_RUN_ID}") + elseif("$ENV{GITLAB_RUN_ID}") + set(PLUGIN_BUILD_NUMBER "$ENV{GITLAB_RUN_ID}") + else() + set(PLUGIN_BUILD_NUMBER "1") + endif() + else() + # Local builds without an existing cache default to 1 + set(PLUGIN_BUILD_NUMBER "1") + endif() + endif() + file(WRITE "${_BUILD_NUMBER_CACHE}" "${PLUGIN_BUILD_NUMBER}") +endif() diff --git a/cpp/obs/cmake/common/buildspec_common.cmake b/cpp/obs/cmake/common/buildspec_common.cmake new file mode 100644 index 000000000..0ed0246b6 --- /dev/null +++ b/cpp/obs/cmake/common/buildspec_common.cmake @@ -0,0 +1,226 @@ +# Common build dependencies module + +include_guard(GLOBAL) + +# _check_deps_version: Checks for obs-deps VERSION file in prefix paths +function(_check_deps_version version) + set(found FALSE) + + foreach(path IN LISTS CMAKE_PREFIX_PATH) + if(EXISTS "${path}/share/obs-deps/VERSION") + if(dependency STREQUAL qt6 AND NOT EXISTS "${path}/lib/cmake/Qt6/Qt6Config.cmake") + set(found FALSE) + continue() + endif() + + file(READ "${path}/share/obs-deps/VERSION" _check_version) + string(REPLACE "\n" "" _check_version "${_check_version}") + string(REPLACE "-" "." _check_version "${_check_version}") + string(REPLACE "-" "." version "${version}") + + if(_check_version VERSION_EQUAL version) + set(found TRUE) + break() + elseif(_check_version VERSION_LESS version) + message( + AUTHOR_WARNING + "Older ${label} version detected in ${path}: \n" + "Found ${_check_version}, require ${version}" + ) + list(REMOVE_ITEM CMAKE_PREFIX_PATH "${path}") + list(APPEND CMAKE_PREFIX_PATH "${path}") + set(CMAKE_PREFIX_PATH ${CMAKE_PREFIX_PATH}) + continue() + else() + message( + AUTHOR_WARNING + "Newer ${label} version detected in ${path}: \n" + "Found ${_check_version}, require ${version}" + ) + set(found TRUE) + break() + endif() + endif() + endforeach() + + return(PROPAGATE found CMAKE_PREFIX_PATH) +endfunction() + +# _setup_obs_studio: Create obs-studio build project, then build libobs and obs-frontend-api +function(_setup_obs_studio) + if(NOT libobs_DIR) + set(_is_fresh --fresh) + endif() + + if(OS_WINDOWS) + set(_cmake_generator "${CMAKE_GENERATOR}") + set(_cmake_arch "-A ${arch},version=${CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION}") + set(_cmake_extra "-DCMAKE_SYSTEM_VERSION=${CMAKE_SYSTEM_VERSION} -DCMAKE_ENABLE_SCRIPTING=OFF") + elseif(OS_MACOS) + set(_cmake_generator "Xcode") + set(_cmake_arch "-DCMAKE_OSX_ARCHITECTURES:STRING=arm64;x86_64") + set(_cmake_extra "-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET}") + endif() + + message(STATUS "Configure ${label} (${arch})") + execute_process( + COMMAND + "${CMAKE_COMMAND}" -S "${dependencies_dir}/${_obs_destination}" -B + "${dependencies_dir}/${_obs_destination}/build_${arch}" -G ${_cmake_generator} "${_cmake_arch}" + -DOBS_CMAKE_VERSION:STRING=3.0.0 -DENABLE_PLUGINS:BOOL=OFF -DENABLE_FRONTEND:BOOL=OFF + -DOBS_VERSION_OVERRIDE:STRING=${_obs_version} "-DCMAKE_PREFIX_PATH=${CMAKE_PREFIX_PATH}" ${_is_fresh} + ${_cmake_extra} + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + OUTPUT_QUIET + ) + message(STATUS "Configure ${label} (${arch}) - done") + + message(STATUS "Build ${label} (Debug - ${arch})") + execute_process( + COMMAND "${CMAKE_COMMAND}" --build build_${arch} --target obs-frontend-api --config Debug --parallel + WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + OUTPUT_QUIET + ) + message(STATUS "Build ${label} (Debug - ${arch}) - done") + + message(STATUS "Build ${label} (Release - ${arch})") + execute_process( + COMMAND "${CMAKE_COMMAND}" --build build_${arch} --target obs-frontend-api --config Release --parallel + WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + OUTPUT_QUIET + ) + message(STATUS "Build ${label} (Reelase - ${arch}) - done") + + message(STATUS "Install ${label} (${arch})") + execute_process( + COMMAND + "${CMAKE_COMMAND}" --install build_${arch} --component Development --config Debug --prefix "${dependencies_dir}" + WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + OUTPUT_QUIET + ) + execute_process( + COMMAND + "${CMAKE_COMMAND}" --install build_${arch} --component Development --config Release --prefix "${dependencies_dir}" + WORKING_DIRECTORY "${dependencies_dir}/${_obs_destination}" + RESULT_VARIABLE _process_result + COMMAND_ERROR_IS_FATAL ANY + OUTPUT_QUIET + ) + message(STATUS "Install ${label} (${arch}) - done") +endfunction() + +# _check_dependencies: Fetch and extract pre-built OBS build dependencies +function(_check_dependencies) + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/buildspec.json" buildspec) + + string(JSON dependency_data GET ${buildspec} dependencies) + + foreach(dependency IN LISTS dependencies_list) + string(JSON data GET ${dependency_data} ${dependency}) + string(JSON version GET ${data} version) + string(JSON hash GET ${data} hashes ${platform}) + string(JSON url GET ${data} baseUrl) + string(JSON label GET ${data} label) + string(JSON revision ERROR_VARIABLE error GET ${data} revision ${platform}) + + message(STATUS "Setting up ${label} (${arch})") + + set(file "${${dependency}_filename}") + set(destination "${${dependency}_destination}") + string(REPLACE "VERSION" "${version}" file "${file}") + string(REPLACE "VERSION" "${version}" destination "${destination}") + string(REPLACE "ARCH" "${arch}" file "${file}") + string(REPLACE "ARCH" "${arch}" destination "${destination}") + if(revision) + string(REPLACE "_REVISION" "_v${revision}" file "${file}") + string(REPLACE "-REVISION" "-v${revision}" file "${file}") + else() + string(REPLACE "_REVISION" "" file "${file}") + string(REPLACE "-REVISION" "" file "${file}") + endif() + + if(EXISTS "${dependencies_dir}/.dependency_${dependency}_${arch}.sha256") + file( + READ "${dependencies_dir}/.dependency_${dependency}_${arch}.sha256" + OBS_DEPENDENCY_${dependency}_${arch}_HASH + ) + endif() + + set(skip FALSE) + if(dependency STREQUAL prebuilt OR dependency STREQUAL qt6) + if(OBS_DEPENDENCY_${dependency}_${arch}_HASH STREQUAL ${hash}) + _check_deps_version(${version}) + + if(found) + set(skip TRUE) + endif() + endif() + endif() + + if(skip) + message(STATUS "Setting up ${label} (${arch}) - skipped") + continue() + endif() + + if(dependency STREQUAL obs-studio) + set(url ${url}/${file}) + else() + set(url ${url}/${version}/${file}) + endif() + + if(NOT EXISTS "${dependencies_dir}/${file}") + message(STATUS "Downloading ${url}") + file(DOWNLOAD "${url}" "${dependencies_dir}/${file}" STATUS download_status EXPECTED_HASH SHA256=${hash}) + + list(GET download_status 0 error_code) + list(GET download_status 1 error_message) + if(error_code GREATER 0) + message(STATUS "Downloading ${url} - Failure") + message(FATAL_ERROR "Unable to download ${url}, failed with error: ${error_message}") + file(REMOVE "${dependencies_dir}/${file}") + else() + message(STATUS "Downloading ${url} - done") + endif() + endif() + + if(NOT OBS_DEPENDENCY_${dependency}_${arch}_HASH STREQUAL ${hash}) + file(REMOVE_RECURSE "${dependencies_dir}/${destination}") + endif() + + if(NOT EXISTS "${dependencies_dir}/${destination}") + file(MAKE_DIRECTORY "${dependencies_dir}/${destination}") + if(dependency STREQUAL obs-studio) + file(ARCHIVE_EXTRACT INPUT "${dependencies_dir}/${file}" DESTINATION "${dependencies_dir}") + else() + file(ARCHIVE_EXTRACT INPUT "${dependencies_dir}/${file}" DESTINATION "${dependencies_dir}/${destination}") + endif() + endif() + + file(WRITE "${dependencies_dir}/.dependency_${dependency}_${arch}.sha256" "${hash}") + + if(dependency STREQUAL prebuilt) + list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}/${destination}") + elseif(dependency STREQUAL qt6) + list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}/${destination}") + elseif(dependency STREQUAL obs-studio) + set(_obs_version ${version}) + set(_obs_destination "${destination}") + list(APPEND CMAKE_PREFIX_PATH "${dependencies_dir}") + endif() + + message(STATUS "Setting up ${label} (${arch}) - done") + endforeach() + + list(REMOVE_DUPLICATES CMAKE_PREFIX_PATH) + + set(CMAKE_PREFIX_PATH ${CMAKE_PREFIX_PATH} CACHE PATH "CMake prefix search path" FORCE) + + _setup_obs_studio() +endfunction() diff --git a/cpp/obs/cmake/common/ccache.cmake b/cpp/obs/cmake/common/ccache.cmake new file mode 100644 index 000000000..577279e0c --- /dev/null +++ b/cpp/obs/cmake/common/ccache.cmake @@ -0,0 +1,25 @@ +# OBS CMake ccache module + +include_guard(GLOBAL) + +if(NOT DEFINED CCACHE_PROGRAM) + message(DEBUG "Trying to find ccache on build host") + find_program(CCACHE_PROGRAM "ccache") + mark_as_advanced(CCACHE_PROGRAM) +endif() + +if(CCACHE_PROGRAM) + message(DEBUG "Trying to find ccache on build host - done") + message(DEBUG "Ccache found as ${CCACHE_PROGRAM}") + option(ENABLE_CCACHE "Enable compiler acceleration with ccache" OFF) + + if(ENABLE_CCACHE) + set(CMAKE_C_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_CXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_OBJC_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_OBJCXX_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + set(CMAKE_CUDA_COMPILER_LAUNCHER "${CCACHE_PROGRAM}") + endif() +else() + message(DEBUG "Trying to find ccache on build host - skipped") +endif() diff --git a/cpp/obs/cmake/common/compiler_common.cmake b/cpp/obs/cmake/common/compiler_common.cmake new file mode 100644 index 000000000..fcd256892 --- /dev/null +++ b/cpp/obs/cmake/common/compiler_common.cmake @@ -0,0 +1,83 @@ +# CMake common compiler options module + +include_guard(GLOBAL) + +# Set C and C++ language standards to C17 and C++17 +set(CMAKE_C_STANDARD 17) +set(CMAKE_C_STANDARD_REQUIRED TRUE) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED TRUE) + +# Set symbols to be hidden by default for C and C++ +set(CMAKE_C_VISIBILITY_PRESET hidden) +set(CMAKE_CXX_VISIBILITY_PRESET hidden) +set(CMAKE_VISIBILITY_INLINES_HIDDEN TRUE) + +# clang options for C, C++, ObjC, and ObjC++ +set( + _obs_clang_common_options + -fno-strict-aliasing + -Wno-trigraphs + -Wno-missing-field-initializers + -Wno-missing-prototypes + -Werror=return-type + -Wunreachable-code + -Wquoted-include-in-framework-header + -Wno-missing-braces + -Wparentheses + -Wswitch + -Wno-unused-function + -Wno-unused-label + -Wunused-parameter + -Wunused-variable + -Wunused-value + -Wempty-body + -Wuninitialized + -Wno-unknown-pragmas + -Wfour-char-constants + -Wconstant-conversion + -Wno-conversion + -Wint-conversion + -Wbool-conversion + -Wenum-conversion + -Wnon-literal-null-conversion + -Wsign-compare + -Wshorten-64-to-32 + -Wpointer-sign + -Wnewline-eof + -Wno-implicit-fallthrough + -Wdeprecated-declarations + -Wno-sign-conversion + -Winfinite-recursion + -Wcomma + -Wno-strict-prototypes + -Wno-semicolon-before-method-body + -Wformat-security + -Wvla + -Wno-error=shorten-64-to-32 +) + +# clang options for C +set(_obs_clang_c_options ${_obs_clang_common_options} -Wno-shadow -Wno-float-conversion) + +# clang options for C++ +set( + _obs_clang_cxx_options + ${_obs_clang_common_options} + -Wno-non-virtual-dtor + -Wno-overloaded-virtual + -Wno-exit-time-destructors + -Wno-shadow + -Winvalid-offsetof + -Wmove + -Werror=block-capture-autoreleasing + -Wrange-loop-analysis +) + +if(CMAKE_CXX_STANDARD GREATER_EQUAL 20) + list(APPEND _obs_clang_cxx_options -fno-char8_t) +endif() + +if(NOT DEFINED CMAKE_COMPILE_WARNING_AS_ERROR) + set(CMAKE_COMPILE_WARNING_AS_ERROR ON) +endif() diff --git a/cpp/obs/cmake/common/helpers_common.cmake b/cpp/obs/cmake/common/helpers_common.cmake new file mode 100644 index 000000000..3dc3b906c --- /dev/null +++ b/cpp/obs/cmake/common/helpers_common.cmake @@ -0,0 +1,49 @@ +# CMake common helper functions module + +include_guard(GLOBAL) + +# check_uuid: Helper function to check for valid UUID +function(check_uuid uuid_string return_value) + set(valid_uuid TRUE) + # gersemi: off + set(uuid_token_lengths 8 4 4 4 12) + # gersemi: on + set(token_num 0) + + string(REPLACE "-" ";" uuid_tokens ${uuid_string}) + list(LENGTH uuid_tokens uuid_num_tokens) + + if(uuid_num_tokens EQUAL 5) + message(DEBUG "UUID ${uuid_string} is valid with 5 tokens.") + foreach(uuid_token IN LISTS uuid_tokens) + list(GET uuid_token_lengths ${token_num} uuid_target_length) + string(LENGTH "${uuid_token}" uuid_actual_length) + if(uuid_actual_length EQUAL uuid_target_length) + string(REGEX MATCH "[0-9a-fA-F]+" uuid_hex_match ${uuid_token}) + if(NOT uuid_hex_match STREQUAL uuid_token) + set(valid_uuid FALSE) + break() + endif() + else() + set(valid_uuid FALSE) + break() + endif() + math(EXPR token_num "${token_num}+1") + endforeach() + else() + set(valid_uuid FALSE) + endif() + message(DEBUG "UUID ${uuid_string} valid: ${valid_uuid}") + set(${return_value} ${valid_uuid} PARENT_SCOPE) +endfunction() + +if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/src/plugin-support.c.in") + configure_file(src/plugin-support.c.in plugin-support.c @ONLY) + add_library(plugin-support STATIC) + target_sources(plugin-support PRIVATE plugin-support.c PUBLIC src/plugin-support.h) + target_include_directories(plugin-support PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/src") + if(OS_LINUX OR OS_FREEBSD OR OS_OPENBSD) + # add fPIC on Linux to prevent shared object errors + set_property(TARGET plugin-support PROPERTY POSITION_INDEPENDENT_CODE ON) + endif() +endif() diff --git a/cpp/obs/cmake/common/osconfig.cmake b/cpp/obs/cmake/common/osconfig.cmake new file mode 100644 index 000000000..a34b161f1 --- /dev/null +++ b/cpp/obs/cmake/common/osconfig.cmake @@ -0,0 +1,21 @@ +# CMake operating system bootstrap module + +include_guard(GLOBAL) + +if(CMAKE_HOST_SYSTEM_NAME STREQUAL "Windows") + set(CMAKE_C_EXTENSIONS FALSE) + set(CMAKE_CXX_EXTENSIONS FALSE) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/windows") + set(OS_WINDOWS TRUE) +elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") + set(CMAKE_C_EXTENSIONS FALSE) + set(CMAKE_CXX_EXTENSIONS FALSE) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos") + set(OS_MACOS TRUE) +elseif(CMAKE_HOST_SYSTEM_NAME MATCHES "Linux|FreeBSD|OpenBSD") + set(CMAKE_C_EXTENSIONS FALSE) + set(CMAKE_CXX_EXTENSIONS FALSE) + list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake/linux") + string(TOUPPER "${CMAKE_HOST_SYSTEM_NAME}" _SYSTEM_NAME_U) + set(OS_${_SYSTEM_NAME_U} TRUE) +endif() diff --git a/cpp/obs/cmake/linux/compilerconfig.cmake b/cpp/obs/cmake/linux/compilerconfig.cmake new file mode 100644 index 000000000..c30b2f4f1 --- /dev/null +++ b/cpp/obs/cmake/linux/compilerconfig.cmake @@ -0,0 +1,78 @@ +# CMake Linux compiler configuration module + +include_guard(GLOBAL) + +include(ccache) +include(compiler_common) + +option(ENABLE_COMPILER_TRACE "Enable Clang time-trace (required Clang and Ninja)" OFF) +mark_as_advanced(ENABLE_COMPILER_TRACE) + +# gcc options for C +set( + _obs_gcc_c_options + -fno-strict-aliasing + -fopenmp-simd + -Wdeprecated-declarations + -Wempty-body + -Wenum-conversion + -Werror=return-type + -Wextra + -Wformat + -Wformat-security + -Wno-conversion + -Wno-float-conversion + -Wno-implicit-fallthrough + -Wno-missing-braces + -Wno-missing-field-initializers + -Wno-shadow + -Wno-sign-conversion + -Wno-trigraphs + -Wno-unknown-pragmas + -Wno-unused-function + -Wno-unused-label + -Wparentheses + -Wuninitialized + -Wunreachable-code + -Wunused-parameter + -Wunused-value + -Wunused-variable + -Wvla +) + +add_compile_options( + -fopenmp-simd + "$<$:${_obs_gcc_c_options}>" + "$<$:-Wint-conversion;-Wno-missing-prototypes;-Wno-strict-prototypes;-Wpointer-sign>" + "$<$:${_obs_gcc_c_options}>" + "$<$:-Winvalid-offsetof;-Wno-overloaded-virtual>" + "$<$:${_obs_clang_c_options}>" + "$<$:${_obs_clang_cxx_options}>" +) + +if(CMAKE_CXX_COMPILER_ID STREQUAL GNU) + # * Disable false-positive warning in GCC 12.1.0 and later + # * https://gcc.gnu.org/bugzilla/show_bug.cgi?id=105562 + if(CMAKE_C_COMPILER_VERSION VERSION_GREATER_EQUAL 12.1.0) + add_compile_options(-Wno-error=maybe-uninitialized) + endif() + + # * Add warning for infinite recursion (added in GCC 12) + # * Also disable warnings for stringop-overflow due to https://gcc.gnu.org/bugzilla/show_bug.cgi?id=106297 + if(CMAKE_C_COMPILER_VERSION VERSION_GREATER_EQUAL 12.0.0) + add_compile_options(-Winfinite-recursion -Wno-stringop-overflow) + endif() + + if(CMAKE_SYSTEM_PROCESSOR STREQUAL aarch64) + add_compile_options(-Wno-error=type-limits) + endif() +endif() + +# Enable compiler and build tracing (requires Ninja generator) +if(ENABLE_COMPILER_TRACE AND CMAKE_GENERATOR STREQUAL "Ninja") + add_compile_options($<$:-ftime-trace> $<$:-ftime-trace>) +else() + set(ENABLE_COMPILER_TRACE OFF CACHE STRING "Enable Clang time-trace (required Clang and Ninja)" FORCE) +endif() + +add_compile_definitions($<$:DEBUG> $<$:_DEBUG> SIMDE_ENABLE_OPENMP) diff --git a/cpp/obs/cmake/linux/defaults.cmake b/cpp/obs/cmake/linux/defaults.cmake new file mode 100644 index 000000000..f38b44ab1 --- /dev/null +++ b/cpp/obs/cmake/linux/defaults.cmake @@ -0,0 +1,88 @@ +# CMake Linux defaults module + +include_guard(GLOBAL) + +# Set default installation directories +include(GNUInstallDirs) + +if(CMAKE_INSTALL_LIBDIR MATCHES "(CMAKE_SYSTEM_PROCESSOR)") + string(REPLACE "CMAKE_SYSTEM_PROCESSOR" "${CMAKE_SYSTEM_PROCESSOR}" CMAKE_INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}") +endif() + +# Enable find_package targets to become globally available targets +set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL TRUE) + +set(CPACK_PACKAGE_NAME "${CMAKE_PROJECT_NAME}") +set(CPACK_PACKAGE_VERSION "${CMAKE_PROJECT_VERSION}") +set(CPACK_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-${CMAKE_C_LIBRARY_ARCHITECTURE}") + +set(CPACK_GENERATOR "DEB") +set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) +set(CPACK_DEBIAN_PACKAGE_MAINTAINER "${PLUGIN_EMAIL}") +set(CPACK_SET_DESTDIR ON) + +if(CMAKE_VERSION VERSION_GREATER_EQUAL 3.25.0 OR NOT CMAKE_CROSSCOMPILING) + set(CPACK_DEBIAN_DEBUGINFO_PACKAGE ON) +endif() + +set(CPACK_OUTPUT_FILE_PREFIX "${CMAKE_CURRENT_SOURCE_DIR}/release") + +set(CPACK_SOURCE_GENERATOR "TXZ") +set( + CPACK_SOURCE_IGNORE_FILES + ".*~$" + \\.git/ + \\.github/ + \\.gitignore + \\.ccache/ + build_.* + cmake/\\.CMakeBuildNumber + release/ +) + +set(CPACK_VERBATIM_VARIABLES YES) +set(CPACK_SOURCE_PACKAGE_FILE_NAME "${CPACK_PACKAGE_NAME}-${CPACK_PACKAGE_VERSION}-source") +set(CPACK_ARCHIVE_THREADS 0) + +include(CPack) + +find_package(libobs QUIET) + +if(NOT TARGET OBS::libobs) + find_package(LibObs REQUIRED) + add_library(OBS::libobs ALIAS libobs) + + if(ENABLE_FRONTEND_API) + find_path( + obs-frontend-api_INCLUDE_DIR + NAMES obs-frontend-api.h + PATHS /usr/include /usr/local/include + PATH_SUFFIXES obs + ) + + find_library(obs-frontend-api_LIBRARY NAMES obs-frontend-api PATHS /usr/lib /usr/local/lib) + + if(obs-frontend-api_LIBRARY) + if(NOT TARGET OBS::obs-frontend-api) + if(IS_ABSOLUTE "${obs-frontend-api_LIBRARY}") + add_library(OBS::obs-frontend-api UNKNOWN IMPORTED) + set_property(TARGET OBS::obs-frontend-api PROPERTY IMPORTED_LOCATION "${obs-frontend-api_LIBRARY}") + else() + add_library(OBS::obs-frontend-api INTERFACE IMPORTED) + set_property(TARGET OBS::obs-frontend-api PROPERTY IMPORTED_LIBNAME "${obs-frontend-api_LIBRARY}") + endif() + + set_target_properties( + OBS::obs-frontend-api + PROPERTIES INTERFACE_INCLUDE_DIRECTORIES "${obs-frontend-api_INCLUDE_DIR}" + ) + endif() + endif() + endif() + + macro(find_package) + if(NOT "${ARGV0}" STREQUAL libobs AND NOT "${ARGV0}" STREQUAL obs-frontend-api) + _find_package(${ARGV}) + endif() + endmacro() +endif() diff --git a/cpp/obs/cmake/linux/helpers.cmake b/cpp/obs/cmake/linux/helpers.cmake new file mode 100644 index 000000000..5b7b84531 --- /dev/null +++ b/cpp/obs/cmake/linux/helpers.cmake @@ -0,0 +1,105 @@ +# CMake Linux helper functions module + +include_guard(GLOBAL) + +include(helpers_common) + +# set_target_properties_plugin: Set target properties for use in obs-studio +function(set_target_properties_plugin target) + set(options "") + set(oneValueArgs "") + set(multiValueArgs PROPERTIES) + cmake_parse_arguments(PARSE_ARGV 0 _STPO "${options}" "${oneValueArgs}" "${multiValueArgs}") + + message(DEBUG "Setting additional properties for target ${target}...") + + while(_STPO_PROPERTIES) + list(POP_FRONT _STPO_PROPERTIES key value) + set_property(TARGET ${target} PROPERTY ${key} "${value}") + endwhile() + + set_target_properties( + ${target} + PROPERTIES VERSION ${PLUGIN_VERSION} SOVERSION ${PLUGIN_VERSION_MAJOR} PREFIX "" + ) + + install( + TARGETS ${target} + RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR} + LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/obs-plugins + ) + + if(TARGET plugin-support) + target_link_libraries(${target} PRIVATE plugin-support) + endif() + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$" + COMMAND + "${CMAKE_COMMAND}" -E copy_if_different "$" "${CMAKE_CURRENT_BINARY_DIR}/rundir/$" + COMMENT "Copy ${target} to rundir" + VERBATIM + ) + + target_install_resources(${target}) + + get_target_property(target_sources ${target} SOURCES) + set(target_ui_files ${target_sources}) + list(FILTER target_ui_files INCLUDE REGEX ".+\\.(ui|qrc)") + source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" PREFIX "UI Files" FILES ${target_ui_files}) +endfunction() + +# Helper function to add resources into bundle +function(target_install_resources target) + message(DEBUG "Installing resources for target ${target}...") + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/data") + file(GLOB_RECURSE data_files "${CMAKE_CURRENT_SOURCE_DIR}/data/*") + foreach(data_file IN LISTS data_files) + cmake_path( + RELATIVE_PATH data_file + BASE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/data/" + OUTPUT_VARIABLE relative_path + ) + cmake_path(GET relative_path PARENT_PATH relative_path) + target_sources(${target} PRIVATE "${data_file}") + source_group("Resources/${relative_path}" FILES "${data_file}") + endforeach() + + install( + DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/data/" + DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/obs/obs-plugins/${target} + USE_SOURCE_PERMISSIONS + ) + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMAND + "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/data" + "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMENT "Copy ${target} resources to rundir" + VERBATIM + ) + endif() +endfunction() + +# Helper function to add a specific resource to a bundle +function(target_add_resource target resource) + message(DEBUG "Add resource '${resource}' to target ${target} at destination '${target_destination}'...") + + install(FILES "${resource}" DESTINATION ${CMAKE_INSTALL_DATAROOTDIR}/obs/obs-plugins/${target}) + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMAND "${CMAKE_COMMAND}" -E copy "${resource}" "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMENT "Copy ${target} resource ${resource} to rundir" + VERBATIM + ) + + source_group("Resources" FILES "${resource}") +endfunction() diff --git a/cpp/obs/cmake/macos/buildspec.cmake b/cpp/obs/cmake/macos/buildspec.cmake new file mode 100644 index 000000000..a86e4d8cf --- /dev/null +++ b/cpp/obs/cmake/macos/buildspec.cmake @@ -0,0 +1,37 @@ +# CMake macOS build dependencies module + +include_guard(GLOBAL) + +include(buildspec_common) + +# _check_dependencies_macos: Set up macOS slice for _check_dependencies +function(_check_dependencies_macos) + set(arch universal) + set(platform macos) + + file(READ "${CMAKE_CURRENT_SOURCE_DIR}/buildspec.json" buildspec) + + set(dependencies_dir "${CMAKE_CURRENT_SOURCE_DIR}/.deps") + set(prebuilt_filename "macos-deps-VERSION-ARCH_REVISION.tar.xz") + set(prebuilt_destination "obs-deps-VERSION-ARCH") + set(qt6_filename "macos-deps-qt6-VERSION-ARCH-REVISION.tar.xz") + set(qt6_destination "obs-deps-qt6-VERSION-ARCH") + set(obs-studio_filename "VERSION.tar.gz") + set(obs-studio_destination "obs-studio-VERSION") + set(dependencies_list prebuilt qt6 obs-studio) + + _check_dependencies() + + # Best-effort: strip Gatekeeper quarantine from the downloaded deps. Not all + # files carry the attribute (and build outputs under .deps may be read-only), + # so a non-zero exit here is expected and must not abort configuration. + execute_process( + COMMAND "xattr" -r -d com.apple.quarantine "${dependencies_dir}" + RESULT_VARIABLE result + ) + + list(APPEND CMAKE_FRAMEWORK_PATH "${dependencies_dir}/Frameworks") + set(CMAKE_FRAMEWORK_PATH ${CMAKE_FRAMEWORK_PATH} PARENT_SCOPE) +endfunction() + +_check_dependencies_macos() diff --git a/cpp/obs/cmake/macos/compilerconfig.cmake b/cpp/obs/cmake/macos/compilerconfig.cmake new file mode 100644 index 000000000..475e8ee48 --- /dev/null +++ b/cpp/obs/cmake/macos/compilerconfig.cmake @@ -0,0 +1,103 @@ +# CMake macOS compiler configuration module + +include_guard(GLOBAL) + +option(ENABLE_COMPILER_TRACE "Enable clang time-trace" OFF) +mark_as_advanced(ENABLE_COMPILER_TRACE) + +if(NOT XCODE) + message(FATAL_ERROR "Building OBS Studio on macOS requires Xcode generator.") +endif() + +include(ccache) +include(compiler_common) + +add_compile_options("$<$>:-fopenmp-simd>") + +# Ensure recent enough Xcode and platform SDK +function(check_sdk_requirements) + set(obs_macos_minimum_sdk 15.0) # Keep in sync with Xcode + set(obs_macos_minimum_xcode 16.0) # Keep in sync with SDK + execute_process( + COMMAND xcrun --sdk macosx --show-sdk-platform-version + OUTPUT_VARIABLE obs_macos_current_sdk + RESULT_VARIABLE result + OUTPUT_STRIP_TRAILING_WHITESPACE + ) + if(NOT result EQUAL 0) + message( + FATAL_ERROR + "Failed to fetch macOS SDK version. " + "Ensure that the macOS SDK is installed and that xcode-select points at the Xcode developer directory." + ) + endif() + message(DEBUG "macOS SDK version: ${obs_macos_current_sdk}") + if(obs_macos_current_sdk VERSION_LESS obs_macos_minimum_sdk) + message( + FATAL_ERROR + "Your macOS SDK version (${obs_macos_current_sdk}) is too low. " + "The macOS ${obs_macos_minimum_sdk} SDK (Xcode ${obs_macos_minimum_xcode}) is required to build OBS." + ) + endif() + execute_process(COMMAND xcrun --find xcodebuild OUTPUT_VARIABLE obs_macos_xcodebuild RESULT_VARIABLE result) + if(NOT result EQUAL 0) + message( + FATAL_ERROR + "Xcode was not found. " + "Ensure you have installed Xcode and that xcode-select points at the Xcode developer directory." + ) + endif() + message(DEBUG "Path to xcodebuild binary: ${obs_macos_xcodebuild}") + if(XCODE_VERSION VERSION_LESS obs_macos_minimum_xcode) + message( + FATAL_ERROR + "Your Xcode version (${XCODE_VERSION}) is too low. Xcode ${obs_macos_minimum_xcode} is required to build OBS." + ) + endif() +endfunction() + +check_sdk_requirements() + +# Enable dSYM generator for release builds +string(APPEND CMAKE_C_FLAGS_RELEASE " -g") +string(APPEND CMAKE_CXX_FLAGS_RELEASE " -g") +string(APPEND CMAKE_OBJC_FLAGS_RELEASE " -g") +string(APPEND CMAKE_OBJCXX_FLAGS_RELEASE " -g") + +# Default ObjC compiler options used by Xcode: +# +# * -Wno-implicit-atomic-properties +# * -Wno-objc-interface-ivars +# * -Warc-repeated-use-of-weak +# * -Wno-arc-maybe-repeated-use-of-weak +# * -Wimplicit-retain-self +# * -Wduplicate-method-match +# * -Wshadow +# * -Wfloat-conversion +# * -Wobjc-literal-conversion +# * -Wno-selector +# * -Wno-strict-selector-match +# * -Wundeclared-selector +# * -Wdeprecated-implementations +# * -Wprotocol +# * -Werror=block-capture-autoreleasing +# * -Wrange-loop-analysis + +# Default ObjC++ compiler options used by Xcode: +# +# * -Wno-non-virtual-dtor + +add_compile_definitions( + $<$>:$<$:DEBUG>> + $<$>:$<$:_DEBUG>> + $<$>:SIMDE_ENABLE_OPENMP> +) + +if(ENABLE_COMPILER_TRACE) + add_compile_options( + $<$>:-ftime-trace> + "$<$:SHELL:-Xfrontend -debug-time-expression-type-checking>" + "$<$:SHELL:-Xfrontend -debug-time-function-bodies>" + ) + add_link_options(LINKER:-print_statistics) +endif() diff --git a/cpp/obs/cmake/macos/defaults.cmake b/cpp/obs/cmake/macos/defaults.cmake new file mode 100644 index 000000000..b3b33c033 --- /dev/null +++ b/cpp/obs/cmake/macos/defaults.cmake @@ -0,0 +1,41 @@ +# CMake macOS defaults module + +include_guard(GLOBAL) + +# Set empty codesigning team if not specified as cache variable +if(NOT DEFINED CODESIGN_TEAM) + set(CODESIGN_TEAM "" CACHE STRING "OBS code signing team for macOS" FORCE) +endif() + +# Set ad-hoc codesigning identity if not specified as cache variable. Kept +# independent of CODESIGN_TEAM so a team without an explicit identity still +# falls back to ad-hoc signing. +if(NOT DEFINED CODESIGN_IDENTITY) + set(CODESIGN_IDENTITY "-" CACHE STRING "OBS code signing identity for macOS" FORCE) +endif() + +include(xcode) + +include(buildspec) + +# Use Applications directory as default install destination +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set( + CMAKE_INSTALL_PREFIX + "$ENV{HOME}/Library/Application Support/obs-studio/plugins" + CACHE STRING + "Default plugin installation directory" + FORCE + ) +endif() + +# Enable find_package targets to become globally available targets +set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL TRUE) +# Enable RPATH support for generated binaries +set(CMAKE_MACOSX_RPATH TRUE) +# Use RPATHs from build tree _in_ the build tree +set(CMAKE_BUILD_WITH_INSTALL_RPATH FALSE) +# Do not add default linker search paths to RPATH +set(CMAKE_INSTALL_RPATH_USE_LINK_PATH FALSE) +# Use common bundle-relative RPATH for installed targets +set(CMAKE_INSTALL_RPATH "@executable_path/../Frameworks") diff --git a/cpp/obs/cmake/macos/helpers.cmake b/cpp/obs/cmake/macos/helpers.cmake new file mode 100644 index 000000000..20d809cc9 --- /dev/null +++ b/cpp/obs/cmake/macos/helpers.cmake @@ -0,0 +1,100 @@ +# CMake macOS helper functions module + +include_guard(GLOBAL) + +include(helpers_common) + +# set_target_properties_obs: Set target properties for use in obs-studio +function(set_target_properties_plugin target) + set(options "") + set(oneValueArgs "") + set(multiValueArgs PROPERTIES) + cmake_parse_arguments(PARSE_ARGV 0 _STPO "${options}" "${oneValueArgs}" "${multiValueArgs}") + + message(DEBUG "Setting additional properties for target ${target}...") + + while(_STPO_PROPERTIES) + list(POP_FRONT _STPO_PROPERTIES key value) + set_property(TARGET ${target} PROPERTY ${key} "${value}") + endwhile() + + string(TIMESTAMP CURRENT_YEAR "%Y") + set_target_properties( + ${target} + PROPERTIES + BUNDLE TRUE + BUNDLE_EXTENSION plugin + XCODE_ATTRIBUTE_PRODUCT_NAME ${target} + XCODE_ATTRIBUTE_PRODUCT_BUNDLE_IDENTIFIER ${MACOS_BUNDLEID} + XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION ${PLUGIN_BUILD_NUMBER} + XCODE_ATTRIBUTE_MARKETING_VERSION ${PLUGIN_VERSION} + XCODE_ATTRIBUTE_GENERATE_INFOPLIST_FILE YES + XCODE_ATTRIBUTE_INFOPLIST_FILE "" + XCODE_ATTRIBUTE_INFOPLIST_KEY_CFBundleDisplayName ${target} + XCODE_ATTRIBUTE_INFOPLIST_KEY_NSHumanReadableCopyright "(c) ${CURRENT_YEAR} ${PLUGIN_AUTHOR}" + XCODE_ATTRIBUTE_INSTALL_PATH "$(USER_LIBRARY_DIR)/Application Support/obs-studio/plugins" + ) + + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos/entitlements.plist") + set_target_properties( + ${target} + PROPERTIES XCODE_ATTRIBUTE_CODE_SIGN_ENTITLEMENTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos/entitlements.plist" + ) + endif() + + if(TARGET plugin-support) + target_link_libraries(${target} PRIVATE plugin-support) + endif() + + target_install_resources(${target}) + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$" + COMMAND + "${CMAKE_COMMAND}" -E copy_directory "$" + "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/$" + COMMENT "Copy ${target} to rundir" + VERBATIM + ) + + get_target_property(target_sources ${target} SOURCES) + set(target_ui_files ${target_sources}) + list(FILTER target_ui_files INCLUDE REGEX ".+\\.(ui|qrc)") + source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" PREFIX "UI Files" FILES ${target_ui_files}) + + install(TARGETS ${target} LIBRARY DESTINATION .) + install(FILES "$.dSYM" CONFIGURATIONS Release DESTINATION . OPTIONAL) + + configure_file(cmake/macos/resources/distribution.in "${CMAKE_CURRENT_BINARY_DIR}/distribution" @ONLY) + configure_file(cmake/macos/resources/create-package.cmake.in "${CMAKE_CURRENT_BINARY_DIR}/create-package.cmake" @ONLY) + install(SCRIPT "${CMAKE_CURRENT_BINARY_DIR}/create-package.cmake") +endfunction() + +# target_install_resources: Helper function to add resources into bundle +function(target_install_resources target) + message(DEBUG "Installing resources for target ${target}...") + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/data") + file(GLOB_RECURSE data_files "${CMAKE_CURRENT_SOURCE_DIR}/data/*") + foreach(data_file IN LISTS data_files) + cmake_path( + RELATIVE_PATH data_file + BASE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/data/" + OUTPUT_VARIABLE relative_path + ) + cmake_path(GET relative_path PARENT_PATH relative_path) + target_sources(${target} PRIVATE "${data_file}") + set_property(SOURCE "${data_file}" PROPERTY MACOSX_PACKAGE_LOCATION "Resources/${relative_path}") + source_group("Resources/${relative_path}" FILES "${data_file}") + endforeach() + endif() +endfunction() + +# target_add_resource: Helper function to add a specific resource to a bundle +function(target_add_resource target resource) + message(DEBUG "Add resource ${resource} to target ${target} at destination ${destination}...") + target_sources(${target} PRIVATE "${resource}") + set_property(SOURCE "${resource}" PROPERTY MACOSX_PACKAGE_LOCATION Resources) + source_group("Resources" FILES "${resource}") +endfunction() diff --git a/cpp/obs/cmake/macos/resources/ccache-launcher-c.in b/cpp/obs/cmake/macos/resources/ccache-launcher-c.in new file mode 100644 index 000000000..1283d673e --- /dev/null +++ b/cpp/obs/cmake/macos/resources/ccache-launcher-c.in @@ -0,0 +1,26 @@ +#!/bin/sh + +if [ "$1" = "${CMAKE_C_COMPILER}" ]; then + shift +fi + +export CCACHE_DIR='${CMAKE_SOURCE_DIR}/.ccache' +export CCACHE_MAXSIZE='1G' +export CCACHE_CPP2=true +export CCACHE_DEPEND=true +export CCACHE_DIRECT=true +export CCACHE_FILECLONE=true +export CCACHE_INODECACHE=true +export CCACHE_COMPILERCHECK='content' + +CCACHE_SLOPPINESS='file_stat_matches,include_file_mtime,include_file_ctime,system_headers' + +if [ "${CMAKE_C_COMPILER_ID}" = "AppleClang" ]; then + CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS},modules,clang_index_store" +fi +export CCACHE_SLOPPINESS + +if [ -n "${CI}" ]; then + export CCACHE_NOHASHDIR=true +fi +exec "${CMAKE_C_COMPILER_LAUNCHER}" "${CMAKE_C_COMPILER}" "$@" diff --git a/cpp/obs/cmake/macos/resources/ccache-launcher-cxx.in b/cpp/obs/cmake/macos/resources/ccache-launcher-cxx.in new file mode 100644 index 000000000..f94d1d74d --- /dev/null +++ b/cpp/obs/cmake/macos/resources/ccache-launcher-cxx.in @@ -0,0 +1,26 @@ +#!/bin/sh + +if [ "$1" = "${CMAKE_CXX_COMPILER}" ]; then + shift +fi + +export CCACHE_DIR='${CMAKE_SOURCE_DIR}/.ccache' +export CCACHE_MAXSIZE='1G' +export CCACHE_CPP2=true +export CCACHE_DEPEND=true +export CCACHE_DIRECT=true +export CCACHE_FILECLONE=true +export CCACHE_INODECACHE=true +export CCACHE_COMPILERCHECK='content' + +CCACHE_SLOPPINESS='file_stat_matches,include_file_mtime,include_file_ctime,system_headers' + +if [ "${CMAKE_CXX_COMPILER_ID}" = "AppleClang" ]; then + CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS},modules,clang_index_store" +fi +export CCACHE_SLOPPINESS + +if [ -n "${CI}" ]; then + export CCACHE_NOHASHDIR=true +fi +exec "${CMAKE_CXX_COMPILER_LAUNCHER}" "${CMAKE_CXX_COMPILER}" "$@" diff --git a/cpp/obs/cmake/macos/resources/create-package.cmake.in b/cpp/obs/cmake/macos/resources/create-package.cmake.in new file mode 100644 index 000000000..a2f1b112a --- /dev/null +++ b/cpp/obs/cmake/macos/resources/create-package.cmake.in @@ -0,0 +1,35 @@ +make_directory("$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/package/Library/Application Support/obs-studio/plugins") + +if(EXISTS "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin" AND NOT IS_SYMLINK "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin") + file(INSTALL DESTINATION "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/package/Library/Application Support/obs-studio/plugins" + TYPE DIRECTORY FILES "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin" USE_SOURCE_PERMISSIONS) + + if(CMAKE_INSTALL_CONFIG_NAME MATCHES "^([Rr][Ee][Ll][Ee][Aa][Ss][Ee])$" OR CMAKE_INSTALL_CONFIG_NAME MATCHES "^([Mm][Ii][Nn][Ss][Ii][Zz][Ee][Rr][Ee][Ll])$") + if(EXISTS "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin.dSYM" AND NOT IS_SYMLINK "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin.dSYM") + file(INSTALL DESTINATION "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/package/Library/Application Support/obs-studio/plugins" TYPE DIRECTORY FILES "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.plugin.dSYM" USE_SOURCE_PERMISSIONS) + endif() + endif() +endif() + +make_directory("$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/temp") + +execute_process( + COMMAND /usr/bin/pkgbuild + --identifier '@MACOS_BUNDLEID@' + --version '@CMAKE_PROJECT_VERSION@' + --root "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/package" + "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/temp/@CMAKE_PROJECT_NAME@.pkg" + COMMAND_ERROR_IS_FATAL ANY + ) + +execute_process( + COMMAND /usr/bin/productbuild + --distribution "@CMAKE_CURRENT_BINARY_DIR@/distribution" + --package-path "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/temp" + "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.pkg" + COMMAND_ERROR_IS_FATAL ANY) + +if(EXISTS "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/@CMAKE_PROJECT_NAME@.pkg") + file(REMOVE_RECURSE "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/temp") + file(REMOVE_RECURSE "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/package") +endif() diff --git a/cpp/obs/cmake/macos/resources/distribution.in b/cpp/obs/cmake/macos/resources/distribution.in new file mode 100644 index 000000000..016043b03 --- /dev/null +++ b/cpp/obs/cmake/macos/resources/distribution.in @@ -0,0 +1,33 @@ + + + + + @CMAKE_PROJECT_NAME@ + + + + + + + #@CMAKE_PROJECT_NAME@.pkg + + + diff --git a/cpp/obs/cmake/macos/resources/installer-macos.pkgproj.in b/cpp/obs/cmake/macos/resources/installer-macos.pkgproj.in new file mode 100644 index 000000000..9a3f89435 --- /dev/null +++ b/cpp/obs/cmake/macos/resources/installer-macos.pkgproj.in @@ -0,0 +1,920 @@ + + + + + PACKAGES + + + MUST-CLOSE-APPLICATION-ITEMS + + MUST-CLOSE-APPLICATIONS + + PACKAGE_FILES + + DEFAULT_INSTALL_LOCATION + / + HIERARCHY + + CHILDREN + + + CHILDREN + + GID + 80 + PATH + Applications + PATH_TYPE + 0 + PERMISSIONS + 509 + TYPE + 1 + UID + 0 + + + CHILDREN + + + CHILDREN + + + CHILDREN + + + CHILDREN + + + BUNDLE_CAN_DOWNGRADE + + BUNDLE_POSTINSTALL_PATH + + PATH_TYPE + 0 + + BUNDLE_PREINSTALL_PATH + + PATH_TYPE + 0 + + CHILDREN + + GID + 80 + PATH + ../release/@CMAKE_INSTALL_CONFIG_NAME@/@CMAKE_PROJECT_NAME@.plugin + PATH_TYPE + 1 + PERMISSIONS + 493 + TYPE + 3 + UID + 0 + + + GID + 80 + PATH + plugins + PATH_TYPE + 2 + PERMISSIONS + 509 + TYPE + 2 + UID + 0 + + + GID + 80 + PATH + obs-studio + PATH_TYPE + 2 + PERMISSIONS + 509 + TYPE + 2 + UID + 0 + + + GID + 80 + PATH + Application Support + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Automator + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Documentation + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Extensions + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Filesystems + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Frameworks + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Input Methods + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Internet Plug-Ins + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + LaunchAgents + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + LaunchDaemons + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + PreferencePanes + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Preferences + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 80 + PATH + Printers + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + PrivilegedHelperTools + PATH_TYPE + 0 + PERMISSIONS + 1005 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + QuickLook + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + QuickTime + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Screen Savers + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Scripts + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Services + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + GID + 0 + PATH + Widgets + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + GID + 0 + PATH + Library + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + CHILDREN + + + CHILDREN + + GID + 0 + PATH + Shared + PATH_TYPE + 0 + PERMISSIONS + 1023 + TYPE + 1 + UID + 0 + + + GID + 80 + PATH + Users + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + + GID + 0 + PATH + / + PATH_TYPE + 0 + PERMISSIONS + 493 + TYPE + 1 + UID + 0 + + PAYLOAD_TYPE + 0 + PRESERVE_EXTENDED_ATTRIBUTES + + SHOW_INVISIBLE + + SPLIT_FORKS + + TREAT_MISSING_FILES_AS_WARNING + + VERSION + 5 + + PACKAGE_SCRIPTS + + POSTINSTALL_PATH + + PATH_TYPE + 0 + + PREINSTALL_PATH + + PATH_TYPE + 0 + + RESOURCES + + + PACKAGE_SETTINGS + + AUTHENTICATION + 0 + CONCLUSION_ACTION + 0 + FOLLOW_SYMBOLIC_LINKS + + IDENTIFIER + @MACOS_BUNDLEID@ + LOCATION + 0 + NAME + @CMAKE_PROJECT_NAME@ + OVERWRITE_PERMISSIONS + + PAYLOAD_SIZE + -1 + REFERENCE_PATH + + RELOCATABLE + + USE_HFS+_COMPRESSION + + VERSION + @CMAKE_PROJECT_VERSION@ + + TYPE + 0 + UUID + @UUID_PACKAGE@ + + + PROJECT + + PROJECT_COMMENTS + + NOTES + + + + PROJECT_PRESENTATION + + BACKGROUND + + APPAREANCES + + DARK_AQUA + + LIGHT_AQUA + + + SHARED_SETTINGS_FOR_ALL_APPAREANCES + + + INSTALLATION TYPE + + HIERARCHIES + + INSTALLER + + LIST + + + CHILDREN + + DESCRIPTION + + OPTIONS + + HIDDEN + + STATE + 1 + + PACKAGE_UUID + @UUID_PACKAGE@ + TITLE + + TYPE + 0 + UUID + @UUID_INSTALLER@ + + + REMOVED + + + + MODE + 0 + + INSTALLATION_STEPS + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewIntroductionController + INSTALLER_PLUGIN + Introduction + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewReadMeController + INSTALLER_PLUGIN + ReadMe + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewLicenseController + INSTALLER_PLUGIN + License + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewDestinationSelectController + INSTALLER_PLUGIN + TargetSelect + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewInstallationTypeController + INSTALLER_PLUGIN + PackageSelection + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewInstallationController + INSTALLER_PLUGIN + Install + LIST_TITLE_KEY + InstallerSectionTitle + + + ICPRESENTATION_CHAPTER_VIEW_CONTROLLER_CLASS + ICPresentationViewSummaryController + INSTALLER_PLUGIN + Summary + LIST_TITLE_KEY + InstallerSectionTitle + + + INTRODUCTION + + LOCALIZATIONS + + + LICENSE + + LOCALIZATIONS + + MODE + 0 + + README + + LOCALIZATIONS + + + SUMMARY + + LOCALIZATIONS + + + TITLE + + LOCALIZATIONS + + + + PROJECT_REQUIREMENTS + + LIST + + + BEHAVIOR + 3 + DICTIONARY + + IC_REQUIREMENT_OS_DISK_TYPE + 1 + IC_REQUIREMENT_OS_DISTRIBUTION_TYPE + 0 + IC_REQUIREMENT_OS_MINIMUM_VERSION + 101300 + + IC_REQUIREMENT_CHECK_TYPE + 0 + IDENTIFIER + fr.whitebox.Packages.requirement.os + MESSAGE + + NAME + Operating System + STATE + + + + RESOURCES + + ROOT_VOLUME_ONLY + + + PROJECT_SETTINGS + + ADVANCED_OPTIONS + + installer-script.domains:enable_currentUserHome + 1 + + BUILD_FORMAT + 0 + BUILD_PATH + + PATH + . + PATH_TYPE + 1 + + EXCLUDED_FILES + + + PATTERNS_ARRAY + + + REGULAR_EXPRESSION + + STRING + .DS_Store + TYPE + 0 + + + PROTECTED + + PROXY_NAME + Remove .DS_Store files + PROXY_TOOLTIP + Remove ".DS_Store" files created by the Finder. + STATE + + + + PATTERNS_ARRAY + + + REGULAR_EXPRESSION + + STRING + .pbdevelopment + TYPE + 0 + + + PROTECTED + + PROXY_NAME + Remove .pbdevelopment files + PROXY_TOOLTIP + Remove ".pbdevelopment" files created by ProjectBuilder or Xcode. + STATE + + + + PATTERNS_ARRAY + + + REGULAR_EXPRESSION + + STRING + CVS + TYPE + 1 + + + REGULAR_EXPRESSION + + STRING + .cvsignore + TYPE + 0 + + + REGULAR_EXPRESSION + + STRING + .cvspass + TYPE + 0 + + + REGULAR_EXPRESSION + + STRING + .svn + TYPE + 1 + + + REGULAR_EXPRESSION + + STRING + .git + TYPE + 1 + + + REGULAR_EXPRESSION + + STRING + .gitignore + TYPE + 0 + + + PROTECTED + + PROXY_NAME + Remove SCM metadata + PROXY_TOOLTIP + Remove helper files and folders used by the CVS, SVN or Git Source Code Management systems. + STATE + + + + PATTERNS_ARRAY + + + REGULAR_EXPRESSION + + STRING + classes.nib + TYPE + 0 + + + REGULAR_EXPRESSION + + STRING + designable.db + TYPE + 0 + + + REGULAR_EXPRESSION + + STRING + info.nib + TYPE + 0 + + + PROTECTED + + PROXY_NAME + Optimize nib files + PROXY_TOOLTIP + Remove "classes.nib", "info.nib" and "designable.nib" files within .nib bundles. + STATE + + + + PATTERNS_ARRAY + + + REGULAR_EXPRESSION + + STRING + Resources Disabled + TYPE + 1 + + + PROTECTED + + PROXY_NAME + Remove Resources Disabled folders + PROXY_TOOLTIP + Remove "Resources Disabled" folders. + STATE + + + + SEPARATOR + + + + NAME + @CMAKE_PROJECT_NAME@ + PAYLOAD_ONLY + + TREAT_MISSING_PRESENTATION_DOCUMENTS_AS_WARNING + + + + TYPE + 0 + VERSION + 2 + + diff --git a/cpp/obs/cmake/macos/xcode.cmake b/cpp/obs/cmake/macos/xcode.cmake new file mode 100644 index 000000000..b232dd3ac --- /dev/null +++ b/cpp/obs/cmake/macos/xcode.cmake @@ -0,0 +1,174 @@ +# CMake macOS Xcode module + +include_guard(GLOBAL) + +set(CMAKE_XCODE_GENERATE_SCHEME TRUE) + +# Use a compiler wrapper to enable ccache in Xcode projects +if(ENABLE_CCACHE AND CCACHE_PROGRAM) + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos/resources/ccache-launcher-c.in" ccache-launcher-c) + configure_file("${CMAKE_CURRENT_SOURCE_DIR}/cmake/macos/resources/ccache-launcher-cxx.in" ccache-launcher-cxx) + + execute_process( + COMMAND chmod a+rx "${CMAKE_CURRENT_BINARY_DIR}/ccache-launcher-c" "${CMAKE_CURRENT_BINARY_DIR}/ccache-launcher-cxx" + ) + set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_CURRENT_BINARY_DIR}/ccache-launcher-c") + set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_CURRENT_BINARY_DIR}/ccache-launcher-cxx") + set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_C_COMPILER}") + set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_CXX_COMPILER}") +endif() + +# Set project variables +set(CMAKE_XCODE_ATTRIBUTE_CURRENT_PROJECT_VERSION ${PLUGIN_BUILD_NUMBER}) +set(CMAKE_XCODE_ATTRIBUTE_DYLIB_COMPATIBILITY_VERSION 1.0.0) +set(CMAKE_XCODE_ATTRIBUTE_MARKETING_VERSION ${PLUGIN_VERSION}) + +# Set deployment target +set(CMAKE_XCODE_ATTRIBUTE_MACOSX_DEPLOYMENT_TARGET ${CMAKE_OSX_DEPLOYMENT_TARGET}) + +if(NOT CODESIGN_TEAM) + # Switch to manual codesigning if no codesigning team is provided + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_STYLE Manual) + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "${CODESIGN_IDENTITY}") +else() + if(CODESIGN_IDENTITY AND NOT CODESIGN_IDENTITY STREQUAL "-") + # Switch to manual codesigning if a non-adhoc codesigning identity is provided + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_STYLE Manual) + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "${CODESIGN_IDENTITY}") + else() + # Switch to automatic codesigning via valid team ID + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_STYLE Automatic) + set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "Apple Development") + endif() + set(CMAKE_XCODE_ATTRIBUTE_DEVELOPMENT_TEAM "${CODESIGN_TEAM}") +endif() + +# Only create a single Xcode project file +set(CMAKE_XCODE_GENERATE_TOP_LEVEL_PROJECT_ONLY TRUE) +# Add all libraries to project link phase (lets Xcode handle linking) +set(CMAKE_XCODE_LINK_BUILD_PHASE_MODE KNOWN_LOCATION) + +# Enable codesigning with secure timestamp when not in Debug configuration (required for Notarization) +set(CMAKE_XCODE_ATTRIBUTE_OTHER_CODE_SIGN_FLAGS[variant=Release] "--timestamp") +set(CMAKE_XCODE_ATTRIBUTE_OTHER_CODE_SIGN_FLAGS[variant=RelWithDebInfo] "--timestamp") +set(CMAKE_XCODE_ATTRIBUTE_OTHER_CODE_SIGN_FLAGS[variant=MinSizeRel] "--timestamp") + +# Enable codesigning with hardened runtime option when not in Debug configuration (required for Notarization) +set(CMAKE_XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME[variant=Release] YES) +set(CMAKE_XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME[variant=RelWithDebInfo] YES) +set(CMAKE_XCODE_ATTRIBUTE_ENABLE_HARDENED_RUNTIME[variant=MinSizeRel] YES) + +# Disable injection of Xcode's base entitlements used for debugging when not in Debug configuration (required for +# Notarization) +set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_INJECT_BASE_ENTITLEMENTS[variant=Release] NO) +set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_INJECT_BASE_ENTITLEMENTS[variant=RelWithDebInfo] NO) +set(CMAKE_XCODE_ATTRIBUTE_CODE_SIGN_INJECT_BASE_ENTITLEMENTS[variant=MinSizeRel] NO) + +# Use Swift version 5.0 by default +set(CMAKE_XCODE_ATTRIBUTE_SWIFT_VERSION 5.0) + +# Use DWARF with separate dSYM files when in Release or MinSizeRel configuration. +# +# * Currently overruled by CMake's Xcode generator, requires adding '-g' flag to raw compiler command line for desired +# output configuration. Report to KitWare. +# +set(CMAKE_XCODE_ATTRIBUTE_DEBUG_INFORMATION_FORMAT[variant=Debug] dwarf) +set(CMAKE_XCODE_ATTRIBUTE_DEBUG_INFORMATION_FORMAT[variant=RelWithDebInfo] dwarf) +set(CMAKE_XCODE_ATTRIBUTE_DEBUG_INFORMATION_FORMAT[variant=Release] dwarf-with-dsym) +set(CMAKE_XCODE_ATTRIBUTE_DEBUG_INFORMATION_FORMAT[variant=MinSizeRel] dwarf-with-dsym) + +# Make all symbols hidden by default (currently overriden by CMake's compiler flags) +set(CMAKE_XCODE_ATTRIBUTE_GCC_SYMBOLS_PRIVATE_EXTERN YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_INLINES_ARE_PRIVATE_EXTERN YES) + +# Strip unused code +set(CMAKE_XCODE_ATTRIBUTE_DEAD_CODE_STRIPPING YES) + +# Build active architecture only in Debug configuration +set(CMAKE_XCODE_ATTRIBUTE_ONLY_ACTIVE_ARCH[variant=Debug] YES) + +# Enable testability in Debug configuration +set(CMAKE_XCODE_ATTRIBUTE_ENABLE_TESTABILITY[variant=Debug] YES) + +# Enable using ARC in ObjC by default +set(CMAKE_XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_ARC YES) +# Enable weak references in manual retain release +set(CMAKE_XCODE_ATTRIBUTE_CLANG_ENABLE_OBJC_WEAK YES) +# Disable strict aliasing +set(CMAKE_XCODE_ATTRIBUTE_GCC_STRICT_ALIASING NO) + +# Set C++ language default to c17 +# +# * CMake explicitly sets the version via compiler flag when transitive dependencies require specific compiler feature +# set, resulting in the flag being added twice. Report to KitWare as a feature request for Xcode generator +# * See also: https://gitlab.kitware.com/cmake/cmake/-/issues/17183 +# +# set(CMAKE_XCODE_ATTRIBUTE_GCC_C_LANGUAGE_STANDARD c17) +# +# Set C++ language default to c++17 +# +# * See above. Report to KitWare as a feature request for Xcode generator +# +# set(CMAKE_XCODE_ATTRIBUTE_CLANG_CXX_LANGUAGE_STANDARD c++17) + +# Enable support for module imports in ObjC +set(CMAKE_XCODE_ATTRIBUTE_CLANG_ENABLE_MODULES YES) +# Enable automatic linking of imported modules in ObjC +set(CMAKE_XCODE_ATTRIBUTE_CLANG_MODULES_AUTOLINK YES) +# Enable strict msg_send rules for ObjC +set(CMAKE_XCODE_ATTRIBUTE_ENABLE_STRICT_OBJC_MSGSEND YES) + +# Set default warnings for ObjC and C++ +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING YES_ERROR) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_BOOL_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_COMMA YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_CONSTANT_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_EMPTY_BODY YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_ENUM_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_INFINITE_RECURSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_INT_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_NON_LITERAL_NULL_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_OBJC_LITERAL_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_RANGE_LOOP_ANALYSIS YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_STRICT_PROTOTYPES NO) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_SUSPICIOUS_IMPLICIT_CONVERSION NO) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_SUSPICIOUS_MOVE YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN_UNREACHABLE_CODE YES) +set(CMAKE_XCODE_ATTRIBUTE_CLANG_WARN__DUPLICATE_METHOD_MATCH YES) + +# Set default warnings for C and C++ +set(CMAKE_XCODE_ATTRIBUTE_GCC_NO_COMMON_BLOCKS YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_64_TO_32_BIT_CONVERSION YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_ABOUT_MISSING_FIELD_INITIALIZERS NO) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_ABOUT_MISSING_NEWLINE YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_ABOUT_RETURN_TYPE YES_ERROR) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_CHECK_SWITCH_STATEMENTS YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_FOUR_CHARACTER_CONSTANTS YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_SHADOW NO) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_SIGN_COMPARE YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_TYPECHECK_CALLS_TO_PRINTF YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNDECLARED_SELECTOR YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNINITIALIZED_AUTOS YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNUSED_FUNCTION NO) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNUSED_PARAMETER YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNUSED_VALUE YES) +set(CMAKE_XCODE_ATTRIBUTE_GCC_WARN_UNUSED_VARIABLE YES) + +# Add additional warning compiler flags +set(CMAKE_XCODE_ATTRIBUTE_WARNING_CFLAGS "-Wvla -Wformat-security") + +if(CMAKE_COMPILE_WARNING_AS_ERROR) + set(CMAKE_XCODE_ATTRIBUTE_GCC_TREAT_WARNINGS_AS_ERRORS YES) +endif() + +# Enable color diagnostics +set(CMAKE_COLOR_DIAGNOSTICS TRUE) + +# Disable usage of RPATH in build or install configurations +set(CMAKE_SKIP_RPATH TRUE) +# Have Xcode set default RPATH entries +set(CMAKE_XCODE_ATTRIBUTE_LD_RUNPATH_SEARCH_PATHS "@executable_path/../Frameworks") diff --git a/cpp/obs/cmake/windows/buildspec.cmake b/cpp/obs/cmake/windows/buildspec.cmake new file mode 100644 index 000000000..e0e5ee094 --- /dev/null +++ b/cpp/obs/cmake/windows/buildspec.cmake @@ -0,0 +1,24 @@ +# CMake Windows build dependencies module + +include_guard(GLOBAL) + +include(buildspec_common) + +# _check_dependencies_windows: Set up Windows slice for _check_dependencies +function(_check_dependencies_windows) + set(arch ${CMAKE_VS_PLATFORM_NAME}) + set(platform windows-${arch}) + + set(dependencies_dir "${CMAKE_CURRENT_SOURCE_DIR}/.deps") + set(prebuilt_filename "windows-deps-VERSION-ARCH-REVISION.zip") + set(prebuilt_destination "obs-deps-VERSION-ARCH") + set(qt6_filename "windows-deps-qt6-VERSION-ARCH-REVISION.zip") + set(qt6_destination "obs-deps-qt6-VERSION-ARCH") + set(obs-studio_filename "VERSION.zip") + set(obs-studio_destination "obs-studio-VERSION") + set(dependencies_list prebuilt qt6 obs-studio) + + _check_dependencies() +endfunction() + +_check_dependencies_windows() diff --git a/cpp/obs/cmake/windows/compilerconfig.cmake b/cpp/obs/cmake/windows/compilerconfig.cmake new file mode 100644 index 000000000..93971a8f7 --- /dev/null +++ b/cpp/obs/cmake/windows/compilerconfig.cmake @@ -0,0 +1,63 @@ +# CMake Windows compiler configuration module + +include_guard(GLOBAL) + +include(compiler_common) + +set(CMAKE_MSVC_DEBUG_INFORMATION_FORMAT ProgramDatabase) + +message(DEBUG "Current Windows API version: ${CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION}") +if(CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION_MAXIMUM) + message(DEBUG "Maximum Windows API version: ${CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION_MAXIMUM}") +endif() + +if(CMAKE_VS_WINDOWS_TARGET_PLATFORM_VERSION VERSION_LESS 10.0.20348) + message( + FATAL_ERROR + "OBS requires Windows 10 SDK version 10.0.20348.0 or more recent.\n" + "Please download and install the most recent Windows platform SDK." + ) +endif() + +set(_obs_msvc_c_options /MP /Zc:__cplusplus /Zc:preprocessor) +set(_obs_msvc_cpp_options /MP /Zc:__cplusplus /Zc:preprocessor) + +if(CMAKE_CXX_STANDARD GREATER_EQUAL 20) + list(APPEND _obs_msvc_cpp_options /Zc:char8_t-) +endif() + +add_compile_options( + /W3 + /utf-8 + /Brepro + /permissive- + "$<$:${_obs_msvc_c_options}>" + "$<$:${_obs_msvc_cpp_options}>" + "$<$:${_obs_clang_c_options}>" + "$<$:${_obs_clang_cxx_options}>" + $<$>:/Gy> + $<$>:/GL> + $<$>:/Oi> +) + +add_compile_definitions( + UNICODE + _UNICODE + _CRT_SECURE_NO_WARNINGS + _CRT_NONSTDC_NO_WARNINGS + $<$:DEBUG> + $<$:_DEBUG> +) + +add_link_options( + $<$>:/OPT:REF> + $<$>:/OPT:ICF> + $<$>:/LTCG> + $<$>:/INCREMENTAL:NO> + /DEBUG + /Brepro +) + +if(CMAKE_COMPILE_WARNING_AS_ERROR) + add_link_options(/WX) +endif() diff --git a/cpp/obs/cmake/windows/defaults.cmake b/cpp/obs/cmake/windows/defaults.cmake new file mode 100644 index 000000000..c6ec842ec --- /dev/null +++ b/cpp/obs/cmake/windows/defaults.cmake @@ -0,0 +1,18 @@ +# CMake Windows defaults module + +include_guard(GLOBAL) + +# Enable find_package targets to become globally available targets +set(CMAKE_FIND_PACKAGE_TARGETS_GLOBAL TRUE) + +include(buildspec) + +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set( + CMAKE_INSTALL_PREFIX + "$ENV{ALLUSERSPROFILE}/obs-studio/plugins" + CACHE STRING + "Default plugin installation directory" + FORCE + ) +endif() diff --git a/cpp/obs/cmake/windows/helpers.cmake b/cpp/obs/cmake/windows/helpers.cmake new file mode 100644 index 000000000..437537961 --- /dev/null +++ b/cpp/obs/cmake/windows/helpers.cmake @@ -0,0 +1,107 @@ +# CMake Windows helper functions module + +include_guard(GLOBAL) + +include(helpers_common) + +# set_target_properties_plugin: Set target properties for use in obs-studio +function(set_target_properties_plugin target) + set(options "") + set(oneValueArgs "") + set(multiValueArgs PROPERTIES) + cmake_parse_arguments(PARSE_ARGV 0 _STPO "${options}" "${oneValueArgs}" "${multiValueArgs}") + + message(DEBUG "Setting additional properties for target ${target}...") + + while(_STPO_PROPERTIES) + list(POP_FRONT _STPO_PROPERTIES key value) + set_property(TARGET ${target} PROPERTY ${key} "${value}") + endwhile() + + string(TIMESTAMP CURRENT_YEAR "%Y") + + set_target_properties(${target} PROPERTIES VERSION 0 SOVERSION ${PLUGIN_VERSION}) + + install(TARGETS ${target} RUNTIME DESTINATION "${target}/bin/64bit" LIBRARY DESTINATION "${target}/bin/64bit") + + install( + FILES "$" + CONFIGURATIONS RelWithDebInfo Debug Release + DESTINATION "${target}/bin/64bit" + OPTIONAL + ) + + if(TARGET plugin-support) + target_link_libraries(${target} PRIVATE plugin-support) + endif() + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$" + COMMAND + "${CMAKE_COMMAND}" -E copy_if_different "$" + "$<$:$>" + "${CMAKE_CURRENT_BINARY_DIR}/rundir/$" + COMMENT "Copy ${target} to rundir" + VERBATIM + ) + + target_install_resources(${target}) + + get_target_property(target_sources ${target} SOURCES) + set(target_ui_files ${target_sources}) + list(FILTER target_ui_files INCLUDE REGEX ".+\\.(ui|qrc)") + source_group(TREE "${CMAKE_CURRENT_SOURCE_DIR}" PREFIX "UI Files" FILES ${target_ui_files}) + + configure_file(cmake/windows/resources/resource.rc.in "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_PROJECT_NAME}.rc") + target_sources(${target} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_PROJECT_NAME}.rc") +endfunction() + +# Helper function to add resources into bundle +function(target_install_resources target) + message(DEBUG "Installing resources for target ${target}...") + if(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/data") + file(GLOB_RECURSE data_files "${CMAKE_CURRENT_SOURCE_DIR}/data/*") + foreach(data_file IN LISTS data_files) + cmake_path( + RELATIVE_PATH data_file + BASE_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/data/" + OUTPUT_VARIABLE relative_path + ) + cmake_path(GET relative_path PARENT_PATH relative_path) + target_sources(${target} PRIVATE "${data_file}") + source_group("Resources/${relative_path}" FILES "${data_file}") + endforeach() + + install(DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/data/" DESTINATION "${target}/data" USE_SOURCE_PERMISSIONS) + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMAND + "${CMAKE_COMMAND}" -E copy_directory "${CMAKE_CURRENT_SOURCE_DIR}/data" + "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMENT "Copy ${target} resources to rundir" + VERBATIM + ) + endif() +endfunction() + +# Helper function to add a specific resource to a bundle +function(target_add_resource target resource) + message(DEBUG "Add resource '${resource}' to target ${target} at destination '${target_destination}'...") + + install(FILES "${resource}" DESTINATION "${target}/data" COMPONENT Runtime) + + add_custom_command( + TARGET ${target} + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMAND "${CMAKE_COMMAND}" -E copy "${resource}" "${CMAKE_CURRENT_BINARY_DIR}/rundir/$/${target}" + COMMENT "Copy ${target} resource ${resource} to rundir" + VERBATIM + ) + source_group("Resources" FILES "${resource}") +endfunction() diff --git a/cpp/obs/cmake/windows/resources/resource.rc.in b/cpp/obs/cmake/windows/resources/resource.rc.in new file mode 100644 index 000000000..5f3b00ec2 --- /dev/null +++ b/cpp/obs/cmake/windows/resources/resource.rc.in @@ -0,0 +1,32 @@ +1 VERSIONINFO + FILEVERSION ${PROJECT_VERSION_MAJOR},${PROJECT_VERSION_MINOR},${PROJECT_VERSION_PATCH},0 + PRODUCTVERSION ${PROJECT_VERSION_MAJOR},${PROJECT_VERSION_MINOR},${PROJECT_VERSION_PATCH},0 + FILEFLAGSMASK 0x0L +#ifdef _DEBUG + FILEFLAGS 0x1L +#else + FILEFLAGS 0x0L +#endif + FILEOS 0x0L + FILETYPE 0x2L + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "${PLUGIN_AUTHOR}" + VALUE "FileDescription", "${PROJECT_NAME}" + VALUE "FileVersion", "${PROJECT_VERSION}" + VALUE "InternalName", "${PROJECT_NAME}" + VALUE "LegalCopyright", "(C) ${CURRENT_YEAR} ${PLUGIN_AUTHOR}" + VALUE "OriginalFilename", "${PROJECT_NAME}" + VALUE "ProductName", "${PROJECT_NAME}" + VALUE "ProductVersion", "${PROJECT_VERSION}" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/cpp/obs/data/locale/en-US.ini b/cpp/obs/data/locale/en-US.ini new file mode 100644 index 000000000..e69de29bb diff --git a/cpp/obs/justfile b/cpp/obs/justfile new file mode 100644 index 000000000..1abe47d85 --- /dev/null +++ b/cpp/obs/justfile @@ -0,0 +1,96 @@ +#!/usr/bin/env just --justfile + +# OBS Studio plugin for MoQ. See doc/bin/obs.md for the full build guide. +# +# Linux: `nix develop` provides obs-studio/qt6/ffmpeg/cmake, then `just obs build`. +# macOS: native, NOT nix. Needs full Xcode; run outside `nix develop` (its +# toolchain breaks the Xcode build). `just obs setup` downloads +# libobs/Qt6/ffmpeg via buildspec.json (obs-deps). +# Windows: needs Visual Studio 2022; same buildspec download (run from Git Bash). +# +# libmoq is built from the in-tree crate (rs/libmoq) via cargo. MOQ_LOCAL points +# CMake at the repo root by default, so there's no prebuilt release to download. + +set quiet + +# List all of the available commands. +default: + just --list + +# Configure via CMake presets. MOQ_LOCAL defaults to the repo root inside +# CMakeLists.txt (so libmoq builds from rs/libmoq); pass `path=/some/moq` to +# override. We deliberately don't compute the path here: `justfile_directory()` +# resolves to the root justfile when this runs as `just obs setup`, so the +# relative math would point outside the repo. +setup preset="" path="": + #!/usr/bin/env bash + set -euo pipefail + PRESET=$(just preset "{{ preset }}") + if [ -n "{{ path }}" ]; then + echo "Configuring with preset: $PRESET and MOQ_LOCAL={{ path }}" + cmake --preset "$PRESET" -DMOQ_LOCAL="{{ path }}" + else + echo "Configuring with preset: $PRESET (MOQ_LOCAL defaults to the repo root)" + cmake --preset "$PRESET" + fi + +# Build via CMake presets. Run `just obs setup` first (it configures + downloads +# deps); not chained here because on macOS setup reconfigures OBS, which is slow. +build preset="": + #!/usr/bin/env bash + set -euo pipefail + PRESET=$(just preset "{{ preset }}") + cmake --build --preset "$PRESET" + +# Copy the freshly built plugin into the OBS user plugin dir and launch OBS. +# Uses the installed OBS (no local OBS build required). macOS only for now. +run: + #!/usr/bin/env bash + set -euo pipefail + if [[ "$OSTYPE" != "darwin"* ]]; then + echo "just run is macOS-only for now; copy build_*/obs-moq.* into your OBS plugin dir manually" >&2 + exit 1 + fi + dest="$HOME/Library/Application Support/obs-studio/plugins" + mkdir -p "$dest" + cp -a build_macos/RelWithDebInfo/obs-moq.plugin "$dest/" + RUST_LOG=debug RUST_BACKTRACE=1 OBS_LOG_LEVEL=debug /Applications/OBS.app/Contents/MacOS/OBS + +# Lint formatting. Skips a tool silently if it isn't on $PATH (matches repo convention). +check: + #!/usr/bin/env bash + set -euo pipefail + if command -v clang-format >/dev/null; then + git ls-files 'src/*.cpp' 'src/*.h' | xargs clang-format --dry-run --Werror + fi + if command -v gersemi >/dev/null; then + gersemi --check CMakeLists.txt cmake + fi + +# Auto-fix formatting. +fix: + #!/usr/bin/env bash + set -euo pipefail + if command -v clang-format >/dev/null; then + git ls-files 'src/*.cpp' 'src/*.h' | xargs clang-format -i + fi + if command -v gersemi >/dev/null; then + gersemi --in-place CMakeLists.txt cmake + fi + +# Detect the CMake preset for the current platform (or use the override). +preset override="": + #!/usr/bin/env bash + set -euo pipefail + if [[ -n "{{ override }}" ]]; then + echo "{{ override }}" + elif [[ "$OSTYPE" == "darwin"* ]]; then + echo "macos" + elif [[ "$OSTYPE" == "linux-gnu"* ]]; then + echo "ubuntu-x86_64" + elif [[ "$OSTYPE" == "msys" || "$OSTYPE" == "cygwin" ]]; then + echo "windows-x64" + else + echo "Unknown platform: $OSTYPE" >&2 + exit 1 + fi diff --git a/cpp/obs/src/logger.h b/cpp/obs/src/logger.h new file mode 100644 index 000000000..d34c9ecc2 --- /dev/null +++ b/cpp/obs/src/logger.h @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +// Logging macros - use MOQ_ prefix to avoid conflicts with OBS log level constants +#define MOQ_LOG(level, format, ...) blog(level, "[obs-moq] " format, ##__VA_ARGS__) +#define LOG_DEBUG(format, ...) MOQ_LOG(400, format, ##__VA_ARGS__) +#define LOG_INFO(format, ...) MOQ_LOG(300, format, ##__VA_ARGS__) +#define LOG_WARNING(format, ...) MOQ_LOG(200, format, ##__VA_ARGS__) +#define LOG_ERROR(format, ...) MOQ_LOG(100, format, ##__VA_ARGS__) diff --git a/cpp/obs/src/moq-dock.cpp b/cpp/obs/src/moq-dock.cpp new file mode 100644 index 000000000..2714611aa --- /dev/null +++ b/cpp/obs/src/moq-dock.cpp @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "moq-dock.h" +#include "logger.h" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#ifndef MOQ_VERSION_STRING +#define MOQ_VERSION_STRING "unknown" +#endif + +namespace { + +// Map OBS's "simple output" encoder aliases to real encoder ids, mirroring the +// table OBS uses internally. Falls back to x264 for anything unrecognized. +const char *SimpleVideoEncoderId(const char *name) +{ + if (!name) + return "obs_x264"; + if (strcmp(name, "x264") == 0 || strcmp(name, "x264_lowcpu") == 0) + return "obs_x264"; + if (strcmp(name, "qsv") == 0) + return "obs_qsv11_v2"; + if (strcmp(name, "qsv_av1") == 0) + return "obs_qsv11_av1_v2"; + if (strcmp(name, "amd") == 0) + return "h264_texture_amf"; + if (strcmp(name, "amd_hevc") == 0) + return "h265_texture_amf"; + if (strcmp(name, "amd_av1") == 0) + return "av1_texture_amf"; + if (strcmp(name, "nvenc") == 0) + return "obs_nvenc_h264_tex"; + if (strcmp(name, "nvenc_hevc") == 0) + return "obs_nvenc_hevc_tex"; + if (strcmp(name, "nvenc_av1") == 0) + return "obs_nvenc_av1_tex"; + if (strcmp(name, "apple_h264") == 0) + return "com.apple.videotoolbox.videoencoder.ave.avc"; + if (strcmp(name, "apple_hevc") == 0) + return "com.apple.videotoolbox.videoencoder.ave.hevc"; + return "obs_x264"; +} + +const char *SimpleAudioEncoderId(const char *name) +{ + if (name && strcmp(name, "opus") == 0) + return "ffmpeg_opus"; + return "ffmpeg_aac"; +} + +std::string SettingsPath() +{ + char *p = obs_module_config_path("dock.json"); + std::string s = p ? p : ""; + bfree(p); + return s; +} + +} // namespace + +MoQDock::MoQDock(QWidget *parent) : QWidget(parent) +{ + urlEdit = new QLineEdit(this); + urlEdit->setText("http://localhost:4443/anon"); + urlEdit->setPlaceholderText("https://cdn.moq.dev/anon"); + + pathEdit = new QLineEdit(this); + pathEdit->setText("obs"); + pathEdit->setPlaceholderText("(optional) broadcast name"); + + // Labels above the fields (WrapAllRows), and let the fields grow to the full + // dock width (the macOS default keeps them at their size hint otherwise). + auto *form = new QFormLayout(); + form->setRowWrapPolicy(QFormLayout::WrapAllRows); + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + form->setContentsMargins(0, 0, 0, 0); + form->addRow("Relay URL", urlEdit); + form->addRow("Broadcast name", pathEdit); + + button = new QPushButton("Go Live", this); + button->setCursor(Qt::PointingHandCursor); + connect(button, &QPushButton::clicked, this, &MoQDock::ToggleStream); + + status = new QLabel(this); + status->setWordWrap(true); + QFont statusFont = status->font(); + statusFont.setBold(true); + status->setFont(statusFont); + + auto *versionLabel = new QLabel(QString("libmoq %1").arg(MOQ_VERSION_STRING), this); + versionLabel->setAlignment(Qt::AlignRight | Qt::AlignBottom); + versionLabel->setStyleSheet("color: #888888; font-size: 10px;"); + + auto *layout = new QVBoxLayout(this); + layout->setSpacing(10); + layout->addLayout(form); + layout->addWidget(button); + layout->addWidget(status); + layout->addStretch(); + layout->addWidget(versionLabel); + + pollTimer = new QTimer(this); + pollTimer->setInterval(1000); + connect(pollTimer, &QTimer::timeout, this, &MoQDock::UpdateStatus); + + connect(urlEdit, &QLineEdit::editingFinished, this, &MoQDock::SaveSettings); + connect(pathEdit, &QLineEdit::editingFinished, this, &MoQDock::SaveSettings); + + LoadSettings(); + SetRunning(false); +} + +MoQDock::~MoQDock() +{ + StopStream(); +} + +void MoQDock::ToggleStream() +{ + if (running) { + StopStream(); + } else { + StartStream(); + } +} + +bool MoQDock::CreateConfiguredEncoders() +{ + config_t *config = obs_frontend_get_profile_config(); + if (!config) { + LOG_ERROR("No profile config available"); + return false; + } + + const char *mode = config_get_string(config, "Output", "Mode"); + const bool advanced = mode && strcmp(mode, "Advanced") == 0; + + OBSDataAutoRelease videoSettings = obs_data_create(); + OBSDataAutoRelease audioSettings = obs_data_create(); + const char *videoId = nullptr; + const char *audioId = nullptr; + int audioBitrate = 0; + size_t audioMixerIdx = 0; + + if (advanced) { + videoId = config_get_string(config, "AdvOut", "Encoder"); + + // Advanced video encoder settings live in a JSON file in the profile dir. + char *profilePath = obs_frontend_get_current_profile_path(); + if (profilePath) { + std::string file = std::string(profilePath) + "/streamEncoder.json"; + bfree(profilePath); + OBSDataAutoRelease loaded = obs_data_create_from_json_file(file.c_str()); + if (loaded) + obs_data_apply(videoSettings, loaded); + } + + audioId = config_get_string(config, "AdvOut", "AudioEncoder"); + int track = (int)config_get_int(config, "AdvOut", "TrackIndex"); + if (track < 1) + track = 1; + // OBS config tracks are 1-based; libobs mixer indices are 0-based. + audioMixerIdx = (size_t)(track - 1); + char key[32]; + snprintf(key, sizeof(key), "Track%dBitrate", track); + audioBitrate = (int)config_get_int(config, "AdvOut", key); + } else { + videoId = SimpleVideoEncoderId(config_get_string(config, "SimpleOutput", "StreamEncoder")); + int videoBitrate = (int)config_get_int(config, "SimpleOutput", "VBitrate"); + if (videoBitrate <= 0) + videoBitrate = 2500; + obs_data_set_int(videoSettings, "bitrate", videoBitrate); + obs_data_set_string(videoSettings, "rate_control", "CBR"); + const char *preset = config_get_string(config, "SimpleOutput", "Preset"); + if (preset) + obs_data_set_string(videoSettings, "preset", preset); + + audioId = SimpleAudioEncoderId(config_get_string(config, "SimpleOutput", "StreamAudioEncoder")); + audioBitrate = (int)config_get_int(config, "SimpleOutput", "ABitrate"); + } + + if (!videoId || !*videoId) + videoId = "obs_x264"; + if (!audioId || !*audioId) + audioId = "ffmpeg_aac"; + if (audioBitrate <= 0) + audioBitrate = 160; + + // MoQ publishes inline headers (avc3/hev1), so force repeat_headers and no + // B-frames, mirroring MoQService::ApplyEncoderSettings. + obs_data_set_bool(videoSettings, "repeat_headers", true); + obs_data_set_int(videoSettings, "bf", 0); + obs_data_set_int(audioSettings, "bitrate", audioBitrate); + + videoEncoder = + OBSEncoderAutoRelease(obs_video_encoder_create(videoId, "moq_dock_video", videoSettings, nullptr)); + audioEncoder = OBSEncoderAutoRelease( + obs_audio_encoder_create(audioId, "moq_dock_audio", audioSettings, audioMixerIdx, nullptr)); + if (!videoEncoder || !audioEncoder) { + LOG_ERROR("Failed to create encoders (%s / %s)", videoId, audioId); + return false; + } + + obs_encoder_set_video(videoEncoder, obs_get_video()); + obs_encoder_set_audio(audioEncoder, obs_get_audio()); + + LOG_INFO("Using configured stream encoders: %s / %s", videoId, audioId); + return true; +} + +void MoQDock::StartStream() +{ + const std::string url = urlEdit->text().toStdString(); + const std::string path = pathEdit->text().toStdString(); + if (url.empty()) { + status->setText("Relay URL is required"); + return; + } + + SaveSettings(); + + // The MoQ output reads the server URL / path from its attached service, so + // build a throwaway service from the dock fields. + OBSDataAutoRelease serviceSettings = obs_data_create(); + obs_data_set_string(serviceSettings, "server", url.c_str()); + obs_data_set_string(serviceSettings, "key", path.c_str()); + service = + OBSServiceAutoRelease(obs_service_create("moq_service", "moq_dock_service", serviceSettings, nullptr)); + if (!service) { + status->setText("Failed to create service"); + return; + } + + if (!CreateConfiguredEncoders()) { + status->setText("Failed to set up encoders"); + return; + } + + output = OBSOutputAutoRelease(obs_output_create("moq_output", "moq_dock_output", nullptr, nullptr)); + if (!output) { + status->setText("Failed to create output"); + return; + } + + obs_output_set_service(output, service); + obs_output_set_video_encoder(output, videoEncoder); + obs_output_set_audio_encoder(output, audioEncoder, 0); + + signal_handler_connect(obs_output_get_signal_handler(output), "stop", OnOutputStopped, this); + + if (!obs_output_start(output)) { + const char *err = obs_output_get_last_error(output); + status->setText(err ? QString("Failed to start: %1").arg(err) : "Failed to start"); + LOG_ERROR("Failed to start MoQ dock output: %s", err ? err : "(no error)"); + StopStream(); + return; + } + + pollTimer->start(); + + SetRunning(true); + status->setText("● Connecting…"); + status->setStyleSheet("color: #d08b1d;"); +} + +void MoQDock::StopStream() +{ + pollTimer->stop(); + + if (output) { + signal_handler_disconnect(obs_output_get_signal_handler(output), "stop", OnOutputStopped, this); + obs_output_stop(output); + } + + output = nullptr; + service = nullptr; + videoEncoder = nullptr; + audioEncoder = nullptr; + + SetRunning(false); +} + +void MoQDock::SetRunning(bool isRunning) +{ + running = isRunning; + + button->setText(isRunning ? "Stop" : "Go Live"); + button->setStyleSheet(QString("QPushButton { padding: 8px; border-radius: 4px; font-weight: bold; " + "color: white; background-color: %1; }" + "QPushButton:hover { background-color: %2; }") + .arg(isRunning ? "#c0392b" : "#2d8a4e") + .arg(isRunning ? "#e04434" : "#36a45e")); + + urlEdit->setEnabled(!isRunning); + pathEdit->setEnabled(!isRunning); + + if (!isRunning) { + status->setText("● Disconnected"); + status->setStyleSheet("color: #888888;"); + } +} + +void MoQDock::UpdateStatus() +{ + if (!output || !running) + return; + + // libmoq surfaces connection state via the session-connect callback, which + // MoQOutput records as the output's connect time; until that fires we're + // still connecting. There's no per-frame stats API to show beyond this. + const bool connected = obs_output_get_connect_time_ms(output) > 0; + status->setText(connected ? "● Connected" : "● Connecting…"); + status->setStyleSheet(connected ? "color: #36a45e;" : "color: #d08b1d;"); +} + +void MoQDock::LoadSettings() +{ + const std::string path = SettingsPath(); + if (path.empty()) + return; + + OBSDataAutoRelease data = obs_data_create_from_json_file(path.c_str()); + if (!data) + return; + + const char *url = obs_data_get_string(data, "url"); + const char *broadcast = obs_data_get_string(data, "path"); + if (url && *url) + urlEdit->setText(url); + if (obs_data_has_user_value(data, "path")) + pathEdit->setText(broadcast ? broadcast : ""); +} + +void MoQDock::SaveSettings() +{ + const std::string path = SettingsPath(); + if (path.empty()) + return; + + QDir().mkpath(QFileInfo(QString::fromStdString(path)).absolutePath()); + + OBSDataAutoRelease data = obs_data_create(); + obs_data_set_string(data, "url", urlEdit->text().toUtf8().constData()); + obs_data_set_string(data, "path", pathEdit->text().toUtf8().constData()); + obs_data_save_json(data, path.c_str()); +} + +void MoQDock::OnOutputStopped(void *data, calldata_t *params) +{ + auto *self = static_cast(data); + long long code = calldata_int(params, "code"); + + // Signals arrive on an OBS thread; bounce to the Qt thread before touching widgets. + QMetaObject::invokeMethod( + self, + [self, code]() { + // StopStream() resets the status to "Idle", so set the failure + // message afterwards or it would be immediately overwritten. + self->StopStream(); + if (code != OBS_OUTPUT_SUCCESS) + self->status->setText(QString("Stopped (code %1)").arg(code)); + }, + Qt::QueuedConnection); +} + +void register_moq_dock() +{ + // OBS takes ownership of the widget; create it without a parent. + auto *dock = new MoQDock(); + obs_frontend_add_dock_by_id("moq_dock", "MoQ", dock); +} diff --git a/cpp/obs/src/moq-dock.h b/cpp/obs/src/moq-dock.h new file mode 100644 index 000000000..aef15d870 --- /dev/null +++ b/cpp/obs/src/moq-dock.h @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include +#include + +class QLineEdit; +class QPushButton; +class QLabel; +class QTimer; + +// A dockable panel that drives the MoQ output directly, without relying on the +// core Settings -> Stream UI (which does not surface third-party services on +// stable OBS yet). The dock owns its own service/output/encoder objects and +// reuses the encoder settings configured in OBS's Output settings. +class MoQDock : public QWidget { + Q_OBJECT + +public: + explicit MoQDock(QWidget *parent = nullptr); + ~MoQDock() override; + +private slots: + void ToggleStream(); + void UpdateStatus(); + +private: + void StartStream(); + void StopStream(); + void SetRunning(bool running); + bool CreateConfiguredEncoders(); + + void LoadSettings(); + void SaveSettings(); + + // Output "stop" signal handler. Fires on a non-UI thread, so it marshals + // back to the Qt thread before touching widgets. + static void OnOutputStopped(void *data, calldata_t *params); + + QLineEdit *urlEdit; + QLineEdit *pathEdit; + QPushButton *button; + QLabel *status; + + QTimer *pollTimer; + + OBSServiceAutoRelease service; + OBSOutputAutoRelease output; + OBSEncoderAutoRelease videoEncoder; + OBSEncoderAutoRelease audioEncoder; + + bool running = false; +}; + +void register_moq_dock(); diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp new file mode 100644 index 000000000..4695eb31a --- /dev/null +++ b/cpp/obs/src/moq-output.cpp @@ -0,0 +1,366 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include + +#include "moq-output.h" +#include "util/util_uint64.h" + +extern "C" { +#include "moq.h" +} + +MoQOutput::MoQOutput(obs_data_t *, obs_output_t *output) + : output(output), + server_url(), + path(), + total_bytes_sent(0), + connect_time_ms(0), + origin(moq_origin_create()), + session(0), + broadcast(moq_publish_create()) +{ +} + +MoQOutput::~MoQOutput() +{ + moq_publish_close(broadcast); + moq_origin_close(origin); + + Stop(); +} + +bool MoQOutput::Start() +{ + obs_service_t *service = obs_output_get_service(output); + if (!service) { + LOG_ERROR("Failed to get service from output"); + obs_output_signal_stop(output, OBS_OUTPUT_ERROR); + return false; + } + + if (!obs_output_can_begin_data_capture(output, 0)) { + LOG_ERROR("Cannot begin data capture"); + return false; + } + + if (!obs_output_initialize_encoders(output, 0)) { + LOG_ERROR("Failed to initialize encoders"); + return false; + } + + const char *server_value = obs_service_get_connect_info(service, OBS_SERVICE_CONNECT_INFO_SERVER_URL); + server_url = server_value ? server_value : ""; + if (server_url.empty()) { + LOG_ERROR("Server URL is empty"); + obs_output_signal_stop(output, OBS_OUTPUT_BAD_PATH); + return false; + } + + // Path (broadcast name) is optional; an empty string publishes to the unnamed broadcast. + const char *path_value = obs_service_get_connect_info(service, OBS_SERVICE_CONNECT_INFO_STREAM_KEY); + path = path_value ? path_value : ""; + + bool found_encoder = false; + for (uint32_t idx = 0; idx < MAX_OUTPUT_VIDEO_ENCODERS; idx++) { + if (obs_output_get_video_encoder2(output, idx)) { + found_encoder = true; + break; + } + } + + if (!found_encoder) { + LOG_ERROR("Failed to get video encoder"); + return false; + } + + LOG_INFO("Connecting to MoQ server: %s", server_url.c_str()); + + connect_start = std::chrono::steady_clock::now(); + + // Create a callback to log when the session is connected or closed + auto session_connect_callback = [](void *user_data, int error_code) { + auto self = static_cast(user_data); + + if (error_code == 0) { + auto elapsed = std::chrono::steady_clock::now() - self->connect_start; + self->connect_time_ms = static_cast(std::chrono::duration_cast(elapsed).count()); + LOG_INFO("MoQ session established (%d ms): %s", self->connect_time_ms, + self->server_url.c_str()); + } else { + LOG_INFO("MoQ session closed (%d): %s", error_code, self->server_url.c_str()); + } + }; + + // Start establishing a session with the MoQ server + // NOTE: You could publish the same broadcasts to multiple sessions if you want (redundant ingest). + session = moq_session_connect(server_url.data(), server_url.size(), origin, 0, session_connect_callback, this); + if (session < 0) { + LOG_ERROR("Failed to initialize MoQ server: %d", session); + return false; + } + + LOG_INFO("Publishing broadcast: %s", path.c_str()); + + // Publish the broadcast to the origin we created. + // TODO: There is currently no unpublish function. + auto result = moq_origin_publish(origin, path.data(), path.size(), broadcast); + if (result < 0) { + LOG_ERROR("Failed to publish broadcast to session: %d", result); + return false; + } + + obs_output_begin_data_capture(output, 0); + + return true; +} + +void MoQOutput::Stop(bool signal) +{ + // Close the session + if (session > 0) { + moq_session_close(session); + session = 0; + } + + for (auto &[encoder, handle] : video_tracks) { + if (handle > 0) + moq_publish_media_close(handle); + } + video_tracks.clear(); + + for (auto &[encoder, handle] : audio_tracks) { + if (handle > 0) + moq_publish_media_close(handle); + } + audio_tracks.clear(); + + if (signal) { + obs_output_signal_stop(output, OBS_OUTPUT_SUCCESS); + } + + return; +} + +void MoQOutput::Data(struct encoder_packet *packet) +{ + if (!packet) { + Stop(false); + obs_output_signal_stop(output, OBS_OUTPUT_ENCODE_ERROR); + return; + } + + if (packet->type == OBS_ENCODER_AUDIO) { + AudioData(packet); + } else if (packet->type == OBS_ENCODER_VIDEO) { + VideoData(packet); + } +} + +void MoQOutput::AudioData(struct encoder_packet *packet) +{ + obs_encoder_t *encoder = packet->encoder; + + auto it = audio_tracks.find(encoder); + if (it == audio_tracks.end()) { + AudioInit(encoder); + it = audio_tracks.find(encoder); + } + if (it == audio_tracks.end() || it->second < 0) { + // We failed to initialize the audio track, so we can't write any data. + return; + } + int handle = it->second; + + // Add ~1 second offset to handle negative PTS from audio priming frames. + // TODO: This is slightly wrong when den is not evenly divisible by num, but close enough. + int64_t pts = packet->pts + packet->timebase_den / packet->timebase_num; + if (pts < 0) { + LOG_WARNING("Dropping audio frame with negative PTS: %lld", (long long)packet->pts); + return; + } + + auto pts_us = util_mul_div64(pts, 1000000ULL * packet->timebase_num, packet->timebase_den); + + auto result = moq_publish_media_frame(handle, packet->data, packet->size, pts_us); + if (result < 0) { + LOG_ERROR("Failed to write audio frame: %d", result); + return; + } + + total_bytes_sent += packet->size; +} + +void MoQOutput::VideoData(struct encoder_packet *packet) +{ + obs_encoder_t *encoder = packet->encoder; + + auto it = video_tracks.find(encoder); + if (it == video_tracks.end()) { + VideoInit(encoder); + it = video_tracks.find(encoder); + } + if (it == video_tracks.end() || it->second < 0) + return; + int handle = it->second; + + // Add ~1 second offset to match audio for A/V sync. + // TODO: This is slightly wrong when den is not evenly divisible by num, but close enough. + int64_t pts = packet->pts + packet->timebase_den / packet->timebase_num; + if (pts < 0) { + LOG_WARNING("Dropping video frame with negative PTS: %lld", (long long)packet->pts); + return; + } + + auto pts_us = util_mul_div64(pts, 1000000ULL * packet->timebase_num, packet->timebase_den); + + auto result = moq_publish_media_frame(handle, packet->data, packet->size, pts_us); + if (result < 0) { + LOG_ERROR("Failed to write video frame: %d", result); + return; + } + + total_bytes_sent += packet->size; +} + +void MoQOutput::VideoInit(obs_encoder_t *encoder) +{ + if (!encoder) { + LOG_ERROR("Failed to get video encoder"); + return; + } + + // TODO Pass these along to the video catalog somehow. + /* + OBSDataAutoRelease settings = obs_encoder_get_settings(encoder); + if (!settings) { + LOG_ERROR("Failed to get video encoder settings"); + return; + } + + auto video_bitrate = (int)obs_data_get_int(settings, "bitrate"); + auto video_width = obs_encoder_get_width(encoder); + auto video_height = obs_encoder_get_height(encoder); + */ + + uint8_t *extra_data = nullptr; + size_t extra_size = 0; + + // obs_encoder_get_extra_data may only return data after the first frame has been encoded. + // For H.264, this returns the SPS/PPS + if (!obs_encoder_get_extra_data(encoder, &extra_data, &extra_size)) { + LOG_WARNING("Failed to get extra data"); + } + + const char *codec = obs_encoder_get_codec(encoder); + + // Transform codec string for MoQ + const char *moq_codec = codec; + if (strcmp(codec, "h264") == 0) { + // H.264 with inline SPS/PPS + moq_codec = "avc3"; + } else if (strcmp(codec, "hevc") == 0) { + // H.265 with inline VPS/SPS/PPS + moq_codec = "hev1"; + } + + // Intialize the media import module with the codec and initialization data. + int handle = moq_publish_media_ordered(broadcast, moq_codec, strlen(moq_codec), extra_data, extra_size); + video_tracks[encoder] = handle; + if (handle < 0) { + LOG_ERROR("Failed to initialize video track: %d", handle); + return; + } + + LOG_INFO("Video track initialized successfully"); +} + +void MoQOutput::AudioInit(obs_encoder_t *encoder) +{ + if (!encoder) { + LOG_ERROR("Failed to get audio encoder"); + return; + } + + // TODO Pass these along to the audio catalog somehow. + /* + OBSDataAutoRelease settings = obs_encoder_get_settings(encoder); + if (!settings) { + LOG_ERROR("Failed to get audio encoder settings"); + return; + } + + auto audio_bitrate = (int)obs_data_get_int(settings, "bitrate"); + */ + + uint8_t *extra_data = nullptr; + size_t extra_size = 0; + + // obs_encoder_get_extra_data may only return data after the first frame has been encoded. + // For AAC, this returns 2 bytes containing the profile and the sample rate. + if (!obs_encoder_get_extra_data(encoder, &extra_data, &extra_size)) { + LOG_WARNING("Failed to get extra data"); + } + + const char *codec = obs_encoder_get_codec(encoder); + + int handle = moq_publish_media_ordered(broadcast, codec, strlen(codec), extra_data, extra_size); + audio_tracks[encoder] = handle; + if (handle < 0) { + LOG_ERROR("Failed to initialize audio track: %d", handle); + return; + } + + LOG_INFO("Audio track initialized successfully"); +} + +void register_moq_output() +{ + const uint32_t base_flags = OBS_OUTPUT_ENCODED | OBS_OUTPUT_SERVICE | OBS_OUTPUT_MULTI_TRACK_VIDEO | + OBS_OUTPUT_MULTI_TRACK_AUDIO; + + const char *audio_codecs = "aac;opus"; + const char *video_codecs = "h264;hevc;av1"; + + struct obs_output_info info = {}; + info.id = "moq_output"; + info.flags = OBS_OUTPUT_AV | base_flags; + info.get_name = [](void *) -> const char * { + return "MoQ Output"; + }; + info.create = [](obs_data_t *settings, obs_output_t *output) -> void * { + return new MoQOutput(settings, output); + }; + info.destroy = [](void *priv_data) { + delete static_cast(priv_data); + }; + info.start = [](void *priv_data) -> bool { + return static_cast(priv_data)->Start(); + }; + info.stop = [](void *priv_data, uint64_t) { + static_cast(priv_data)->Stop(); + }; + info.encoded_packet = [](void *priv_data, struct encoder_packet *packet) { + static_cast(priv_data)->Data(packet); + }; + info.get_total_bytes = [](void *priv_data) -> uint64_t { + return (uint64_t)static_cast(priv_data)->GetTotalBytes(); + }; + info.get_connect_time_ms = [](void *priv_data) -> int { + return static_cast(priv_data)->GetConnectTime(); + }; + info.encoded_video_codecs = video_codecs; + info.encoded_audio_codecs = audio_codecs; + info.protocols = "MoQ"; + + obs_register_output(&info); + + info.id = "moq_output_video"; + info.flags = OBS_OUTPUT_VIDEO | base_flags; + info.encoded_audio_codecs = nullptr; + obs_register_output(&info); + + info.id = "moq_output_audio"; + info.flags = OBS_OUTPUT_AUDIO | base_flags; + info.encoded_video_codecs = nullptr; + info.encoded_audio_codecs = audio_codecs; + obs_register_output(&info); +} diff --git a/cpp/obs/src/moq-output.h b/cpp/obs/src/moq-output.h new file mode 100644 index 000000000..486e1e2ed --- /dev/null +++ b/cpp/obs/src/moq-output.h @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once +#include + +#include +#include +#include +#include "logger.h" + +class MoQOutput +{ + public: + MoQOutput(obs_data_t *settings, obs_output_t *output); + ~MoQOutput(); + + bool Start(); + void Stop(bool signal = true); + void Data(struct encoder_packet *packet); + + inline size_t GetTotalBytes() + { + return total_bytes_sent; + } + + inline int GetConnectTime() + { + return connect_time_ms; + } + + private: + void VideoInit(obs_encoder_t *encoder); + void VideoData(struct encoder_packet *packet); + void AudioInit(obs_encoder_t *encoder); + void AudioData(struct encoder_packet *packet); + + obs_output_t *output; + + std::string server_url; + std::string path; + + size_t total_bytes_sent; + int connect_time_ms; + std::chrono::steady_clock::time_point connect_start; + + int origin; + int session; + int broadcast; + std::map video_tracks; + std::map audio_tracks; +}; + +void register_moq_output(); diff --git a/cpp/obs/src/moq-service.cpp b/cpp/obs/src/moq-service.cpp new file mode 100644 index 000000000..367e3734f --- /dev/null +++ b/cpp/obs/src/moq-service.cpp @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "moq-service.h" + +// TODO: Define supported codecs. +const char *audio_codecs[] = {"aac", "opus", nullptr}; +const char *video_codecs[] = {"h264", "hevc", "av1", nullptr}; + +MoQService::MoQService(obs_data_t *settings, obs_service_t *) : server(), path() +{ + Update(settings); +} + +void MoQService::Update(obs_data_t *settings) +{ + server = obs_data_get_string(settings, "server"); + path = obs_data_get_string(settings, "key"); +} + +obs_properties_t *MoQService::Properties() +{ + obs_properties_t *ppts = obs_properties_create(); + + // Adds properties to be modified by the UI. + // obs_property_t *obs_properties_add_text(obs_properties_t *props, const char *name, const char *desc, enum obs_text_type type) + obs_properties_add_text(ppts, "server", "URL", OBS_TEXT_DEFAULT); + obs_properties_add_text(ppts, "key", "Path (optional)", OBS_TEXT_DEFAULT); + + return ppts; +} + +void MoQService::ApplyEncoderSettings(obs_data_t *video_settings, obs_data_t *audio_settings) +{ + /* + This function is called to apply custom encoder settings specific to this service. + For example, if a service requires a specific keyframe interval, or has a bitrate limit, + the settings for the video and audio encoders can be optionally modified + if the front-end optionally calls. + */ + + // Example: + if (video_settings) { + obs_data_set_int(video_settings, "bf", 0); + obs_data_set_bool(video_settings, "repeat_headers", true); + } + + if (audio_settings) { + obs_data_set_int(audio_settings, "bf", 0); + } +} + +const char *MoQService::GetConnectInfo(enum obs_service_connect_info type) +{ + switch (type) { + case OBS_SERVICE_CONNECT_INFO_SERVER_URL: + return server.c_str(); + case OBS_SERVICE_CONNECT_INFO_STREAM_KEY: + return path.c_str(); + default: + return nullptr; + } +} + +bool MoQService::CanTryToConnect() +{ + return !server.empty(); +} + +void register_moq_service() +{ + struct obs_service_info info = {}; + + info.id = "moq_service"; + info.get_name = [](void *) -> const char * { + return "MoQ (Debug)"; + }; + info.create = [](obs_data_t *settings, obs_service_t *service) -> void * { + return new MoQService(settings, service); + }; + info.destroy = [](void *priv_data) { + delete static_cast(priv_data); + }; + info.update = [](void *priv_data, obs_data_t *settings) { + static_cast(priv_data)->Update(settings); + }; + info.get_properties = [](void *) -> obs_properties_t * { + return MoQService::Properties(); + }; + info.get_protocol = [](void *) -> const char * { + return "MoQ"; + }; + info.get_url = [](void *priv_data) -> const char * { + return static_cast(priv_data)->server.c_str(); + }; + info.get_output_type = [](void *) -> const char * { + return "moq_output"; + }; + info.apply_encoder_settings = [](void *, obs_data_t *video_settings, obs_data_t *audio_settings) { + MoQService::ApplyEncoderSettings(video_settings, audio_settings); + }; + info.get_supported_video_codecs = [](void *) -> const char ** { + return video_codecs; + }; + info.get_supported_audio_codecs = [](void *) -> const char ** { + return audio_codecs; + }; + info.can_try_to_connect = [](void *priv_data) -> bool { + return static_cast(priv_data)->CanTryToConnect(); + }; + info.get_connect_info = [](void *priv_data, uint32_t type) -> const char * { + return static_cast(priv_data)->GetConnectInfo((enum obs_service_connect_info)type); + }; + obs_register_service(&info); +} diff --git a/cpp/obs/src/moq-service.h b/cpp/obs/src/moq-service.h new file mode 100644 index 000000000..2c910a032 --- /dev/null +++ b/cpp/obs/src/moq-service.h @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once +#include +#include + +struct MoQService { + // TODO: Define needed params to connect to a relay + std::string server; + std::string path; + + MoQService(obs_data_t *settings, obs_service_t *service); + + void Update(obs_data_t *settings); + static obs_properties_t *Properties(); + static void ApplyEncoderSettings(obs_data_t *video_settings, obs_data_t *audio_settings); + bool CanTryToConnect(); + const char *GetConnectInfo(enum obs_service_connect_info type); +}; + +void register_moq_service(); diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp new file mode 100644 index 000000000..74a3c795b --- /dev/null +++ b/cpp/obs/src/moq-source.cpp @@ -0,0 +1,1023 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include +#include +#include +#include +#include + +#include + +#ifdef _WIN32 +#define strncasecmp _strnicmp +#endif +extern "C" { +#include +#include +#include +#include +#include "moq.h" +} + +#include "moq-source.h" +#include "logger.h" + +// Map codec string from moq_video_config to FFmpeg codec ID +static AVCodecID codec_string_to_id(const char *codec, size_t len) +{ + if (!codec || len == 0) { + return AV_CODEC_ID_NONE; + } + + // H.264/AVC + if ((len >= 4 && strncasecmp(codec, "h264", 4) == 0) || + (len >= 3 && strncasecmp(codec, "avc", 3) == 0)) { + return AV_CODEC_ID_H264; + } + + // HEVC/H.265 + if ((len >= 4 && strncasecmp(codec, "hevc", 4) == 0) || + (len >= 4 && strncasecmp(codec, "h265", 4) == 0) || + (len >= 4 && strncasecmp(codec, "hev1", 4) == 0) || + (len >= 4 && strncasecmp(codec, "hvc1", 4) == 0)) { + return AV_CODEC_ID_HEVC; + } + + // VP9 + if ((len >= 3 && strncasecmp(codec, "vp9", 3) == 0) || + (len >= 4 && strncasecmp(codec, "vp09", 4) == 0)) { + return AV_CODEC_ID_VP9; + } + + // AV1 + if ((len >= 3 && strncasecmp(codec, "av1", 3) == 0) || + (len >= 4 && strncasecmp(codec, "av01", 4) == 0)) { + return AV_CODEC_ID_AV1; + } + + // VP8 + if (len >= 3 && strncasecmp(codec, "vp8", 3) == 0) { + return AV_CODEC_ID_VP8; + } + + return AV_CODEC_ID_NONE; +} + +struct moq_source { + obs_source_t *source; + + // Settings - current active connection settings + char *url; + char *broadcast; + + // Shutdown flag - set when destroy begins, callbacks should exit early + std::atomic shutting_down; + + // Session handles (all negative = invalid) + std::atomic generation; // Increments on reconnect + bool reconnect_in_progress; // True while reconnect is happening + int32_t origin; + int32_t session; + int32_t consume; + int32_t catalog_handle; + int32_t video_track; + + // Decoder state + AVCodecContext *codec_ctx; + AVCodecID current_codec_id; // Currently configured codec + enum AVPixelFormat current_pix_fmt; // Current pixel format for sws_ctx + struct SwsContext *sws_ctx; + bool got_keyframe; + uint32_t frames_waiting_for_keyframe; // Count of skipped frames while waiting + uint32_t consecutive_decode_errors; // Count of consecutive decode failures + + // Output frame buffer + struct obs_source_frame frame; + uint8_t *frame_buffer; + + // Threading + pthread_mutex_t mutex; +}; + +// Forward declarations +static void moq_source_update(void *data, obs_data_t *settings); +static void moq_source_destroy(void *data); +static obs_properties_t *moq_source_properties(void *data); +static void moq_source_get_defaults(obs_data_t *settings); + +// MoQ callbacks +static void on_session_status(void *user_data, int32_t code); +static void on_catalog(void *user_data, int32_t catalog); +static void on_video_frame(void *user_data, int32_t frame_id); + +// Helper functions +static void moq_source_reconnect(struct moq_source *ctx); +static void moq_source_disconnect_locked(struct moq_source *ctx); +static void moq_source_blank_video(struct moq_source *ctx); +static bool moq_source_init_decoder(struct moq_source *ctx, const struct moq_video_config *config); +static void moq_source_destroy_decoder_locked(struct moq_source *ctx); +static void moq_source_decode_frame(struct moq_source *ctx, int32_t frame_id); + +static void *moq_source_create(obs_data_t *settings, obs_source_t *source) +{ + struct moq_source *ctx = (struct moq_source *)bzalloc(sizeof(struct moq_source)); + ctx->source = source; + + // Initialize shutdown flag + ctx->shutting_down = false; + + // Initialize handles to invalid values + ctx->generation = 0; + ctx->reconnect_in_progress = false; + ctx->origin = -1; + ctx->session = -1; + ctx->consume = -1; + ctx->catalog_handle = -1; + ctx->video_track = -1; + + // Initialize decoder state + ctx->codec_ctx = NULL; + ctx->current_codec_id = AV_CODEC_ID_NONE; + ctx->current_pix_fmt = AV_PIX_FMT_NONE; + ctx->sws_ctx = NULL; + ctx->got_keyframe = false; + ctx->frames_waiting_for_keyframe = 0; + ctx->consecutive_decode_errors = 0; + ctx->frame_buffer = NULL; + + // Initialize threading + pthread_mutex_init(&ctx->mutex, NULL); + + // Initialize OBS frame structure - dimensions will be set dynamically from stream + ctx->frame.width = 0; + ctx->frame.height = 0; + ctx->frame.format = VIDEO_FORMAT_RGBA; + ctx->frame.linesize[0] = 0; + + // Load settings from OBS - this will auto-connect if settings are valid + // (moq_source_update detects settings changed from NULL and reconnects) + moq_source_update(ctx, settings); + + return ctx; +} + +static void moq_source_destroy(void *data) +{ + struct moq_source *ctx = (struct moq_source *)data; + + // Set shutdown flag first - callbacks will check this and exit early + pthread_mutex_lock(&ctx->mutex); + ctx->shutting_down = true; + moq_source_disconnect_locked(ctx); + pthread_mutex_unlock(&ctx->mutex); + + // Give MoQ callbacks time to drain - they check shutting_down and exit early. + // This prevents use-after-free when async callbacks fire after ctx is freed. + // + // LIMITATION: This 100ms sleep is a timing-based workaround, not a synchronization + // guarantee. If a callback is mid-execution when shutting_down is set AND takes + // longer than 100ms to complete (after the mutex unlock), there is still a + // potential race condition. In practice, our callbacks are fast (< 1ms typically) + // and this delay provides sufficient margin. However, a more robust solution + // would use reference counting: + // - Increment refcount when entering a callback + // - Decrement when exiting + // - Wait for refcount to reach zero before freeing ctx + // This could be implemented using std::shared_ptr or a manual atomic refcount + // with a condition variable for waiting. + os_sleep_ms(100); + + bfree(ctx->url); + bfree(ctx->broadcast); + // Note: frame_buffer is already freed by moq_source_disconnect_locked + + pthread_mutex_destroy(&ctx->mutex); + + bfree(ctx); +} + +static void moq_source_update(void *data, obs_data_t *settings) +{ + struct moq_source *ctx = (struct moq_source *)data; + + const char *url = obs_data_get_string(settings, "url"); + const char *broadcast = obs_data_get_string(settings, "broadcast"); + + pthread_mutex_lock(&ctx->mutex); + + // Check if settings actually changed + bool url_changed = (!ctx->url && url && strlen(url) > 0) || + (ctx->url && !url) || + (ctx->url && url && strcmp(ctx->url, url) != 0); + bool broadcast_changed = (!ctx->broadcast && broadcast && strlen(broadcast) > 0) || + (ctx->broadcast && !broadcast) || + (ctx->broadcast && broadcast && strcmp(ctx->broadcast, broadcast) != 0); + bool settings_changed = url_changed || broadcast_changed; + + // Store the new settings + bfree(ctx->url); + ctx->url = bstrdup(url); + bfree(ctx->broadcast); + ctx->broadcast = bstrdup(broadcast); + + // Check if new settings are valid for connection + bool valid = ctx->url && ctx->broadcast && + strlen(ctx->url) > 0 && strlen(ctx->broadcast) > 0; + + pthread_mutex_unlock(&ctx->mutex); + + // If settings changed and are valid, reconnect + if (settings_changed && valid) { + LOG_INFO("Settings changed, reconnecting (url=%s, broadcast=%s)", + url ? url : "(null)", broadcast ? broadcast : "(null)"); + moq_source_reconnect(ctx); + } else if (settings_changed && !valid) { + LOG_INFO("Settings changed but invalid - disconnecting"); + pthread_mutex_lock(&ctx->mutex); + moq_source_disconnect_locked(ctx); + pthread_mutex_unlock(&ctx->mutex); + moq_source_blank_video(ctx); + } +} + +static void moq_source_get_defaults(obs_data_t *settings) +{ + obs_data_set_default_string(settings, "url", "http://localhost:4443"); + obs_data_set_default_string(settings, "broadcast", "obs/test"); +} + +static obs_properties_t *moq_source_properties(void *data) +{ + UNUSED_PARAMETER(data); + + obs_properties_t *props = obs_properties_create(); + + obs_properties_add_text(props, "url", "URL", OBS_TEXT_DEFAULT); + obs_properties_add_text(props, "broadcast", "Broadcast", OBS_TEXT_DEFAULT); + + return props; +} + +// Forward declaration for use in callback +static void moq_source_start_consume(struct moq_source *ctx, uint32_t expected_gen); + +// MoQ callback implementations +static void on_session_status(void *user_data, int32_t code) +{ + struct moq_source *ctx = (struct moq_source *)user_data; + + // Fast path: check atomic flag before taking lock + if (ctx->shutting_down.load()) { + LOG_DEBUG("Ignoring session status callback - shutting down"); + return; + } + + pthread_mutex_lock(&ctx->mutex); + // Double-check after acquiring lock (may have changed) + if (ctx->shutting_down.load()) { + pthread_mutex_unlock(&ctx->mutex); + return; + } + if (ctx->session < 0) { + LOG_DEBUG("Ignoring session status callback - already disconnected"); + pthread_mutex_unlock(&ctx->mutex); + return; + } + uint32_t current_gen = ctx->generation; + + if (code == 0) { + pthread_mutex_unlock(&ctx->mutex); + LOG_INFO("MoQ session connected successfully (generation %u)", current_gen); + // Now that we're connected, start consuming the broadcast + moq_source_start_consume(ctx, current_gen); + } else { + // Connection failed - clean up the session and origin immediately + LOG_ERROR("MoQ session failed with code: %d (generation %u)", code, current_gen); + + // Clean up failed session/origin to prevent further callbacks + if (ctx->session >= 0) { + moq_session_close(ctx->session); + ctx->session = -1; + } + if (ctx->origin >= 0) { + moq_origin_close(ctx->origin); + ctx->origin = -1; + } + pthread_mutex_unlock(&ctx->mutex); + + // Blank the video to show error state + moq_source_blank_video(ctx); + } +} + +static void on_catalog(void *user_data, int32_t catalog) +{ + struct moq_source *ctx = (struct moq_source *)user_data; + + LOG_INFO("Catalog callback received: %d", catalog); + + // Fast path: check atomic flag before taking lock + if (ctx->shutting_down.load()) { + LOG_DEBUG("Ignoring catalog callback - shutting down"); + if (catalog >= 0) + moq_consume_catalog_close(catalog); + return; + } + + pthread_mutex_lock(&ctx->mutex); + + // Double-check after acquiring lock (may have changed) + if (ctx->shutting_down.load()) { + pthread_mutex_unlock(&ctx->mutex); + if (catalog >= 0) + moq_consume_catalog_close(catalog); + return; + } + + // Check if this callback is still valid (not from a stale connection) + uint32_t current_gen = ctx->generation; + if (ctx->consume < 0) { + // We've been disconnected, ignore this callback + pthread_mutex_unlock(&ctx->mutex); + if (catalog >= 0) + moq_consume_catalog_close(catalog); + return; + } + + pthread_mutex_unlock(&ctx->mutex); + + if (catalog < 0) { + LOG_ERROR("Failed to get catalog: %d", catalog); + // Catalog failed (likely invalid broadcast) - blank video + moq_source_blank_video(ctx); + return; + } + + // Get video configuration + struct moq_video_config video_config; + if (moq_consume_video_config(catalog, 0, &video_config) < 0) { + LOG_ERROR("Failed to get video config"); + moq_consume_catalog_close(catalog); + return; + } + + // Initialize decoder with the video config (takes mutex internally) + if (!moq_source_init_decoder(ctx, &video_config)) { + LOG_ERROR("Failed to initialize decoder"); + moq_consume_catalog_close(catalog); + return; + } + + // Subscribe to video track with minimal buffering + // Note: moq_consume_video_ordered takes the catalog handle, not the consume handle + int32_t track = moq_consume_video_ordered(catalog, 0, 0, on_video_frame, ctx); + if (track < 0) { + LOG_ERROR("Failed to subscribe to video track: %d", track); + moq_consume_catalog_close(catalog); + return; + } + + pthread_mutex_lock(&ctx->mutex); + if (ctx->generation == current_gen) { + ctx->video_track = track; + ctx->catalog_handle = catalog; + } else { + // Generation changed while we were setting up, clean up the track + pthread_mutex_unlock(&ctx->mutex); + moq_consume_video_close(track); + moq_consume_catalog_close(catalog); + return; + } + pthread_mutex_unlock(&ctx->mutex); + + LOG_INFO("Subscribed to video track successfully"); +} + +static void on_video_frame(void *user_data, int32_t frame_id) +{ + struct moq_source *ctx = (struct moq_source *)user_data; + + if (frame_id < 0) { + LOG_ERROR("Video frame callback with error: %d", frame_id); + return; + } + + // Fast path: check atomic flag before taking lock + if (ctx->shutting_down.load()) { + moq_consume_frame_close(frame_id); + return; + } + + // Check if this callback is still valid using generation (not video_track) + // Note: We can't check video_track here because frames may arrive before + // the track handle is stored in on_catalog (race condition) + pthread_mutex_lock(&ctx->mutex); + // Double-check after acquiring lock (may have changed) + if (ctx->shutting_down.load()) { + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + if (ctx->consume < 0) { + // We've been disconnected, ignore this callback + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + pthread_mutex_unlock(&ctx->mutex); + + moq_source_decode_frame(ctx, frame_id); +} + +// Helper function implementations +static void moq_source_reconnect(struct moq_source *ctx) +{ + // Increment generation to invalidate old callbacks + pthread_mutex_lock(&ctx->mutex); + + // Check if reconnect is already in progress + if (ctx->reconnect_in_progress) { + LOG_DEBUG("Reconnect already in progress, skipping"); + pthread_mutex_unlock(&ctx->mutex); + return; + } + + ctx->reconnect_in_progress = true; + uint32_t new_gen = ctx->generation.load() + 1; + LOG_INFO("Reconnecting (generation %u -> %u)", ctx->generation.load(), new_gen); + ctx->generation.store(new_gen); + moq_source_disconnect_locked(ctx); + + // Copy URL while holding mutex for thread safety + char *url_copy = bstrdup(ctx->url); + pthread_mutex_unlock(&ctx->mutex); + + // Blank video while reconnecting to avoid showing stale frames + moq_source_blank_video(ctx); + + // Small delay to allow MoQ library to fully clean up previous connection + os_sleep_ms(50); + + // Create origin for consuming (outside mutex since it may block) + int32_t new_origin = moq_origin_create(); + if (new_origin < 0) { + LOG_ERROR("Failed to create origin: %d", new_origin); + bfree(url_copy); + pthread_mutex_lock(&ctx->mutex); + ctx->reconnect_in_progress = false; + pthread_mutex_unlock(&ctx->mutex); + return; + } + + // Connect to MoQ server (consume will happen in on_session_status callback) + int32_t new_session = moq_session_connect( + url_copy, strlen(url_copy), + 0, // origin_publish + new_origin, // origin_consume + on_session_status, ctx + ); + bfree(url_copy); + + if (new_session < 0) { + LOG_ERROR("Failed to connect to MoQ server: %d", new_session); + moq_origin_close(new_origin); + pthread_mutex_lock(&ctx->mutex); + ctx->reconnect_in_progress = false; + pthread_mutex_unlock(&ctx->mutex); + return; + } + + // Now update ctx with the new handles, checking if generation changed + pthread_mutex_lock(&ctx->mutex); + if (ctx->generation != new_gen) { + // Another reconnect happened while we were creating origin/session + // Clean up our newly created resources + ctx->reconnect_in_progress = false; + pthread_mutex_unlock(&ctx->mutex); + LOG_INFO("Generation changed during reconnect setup, cleaning up stale resources"); + moq_session_close(new_session); + moq_origin_close(new_origin); + return; + } + ctx->origin = new_origin; + ctx->session = new_session; + ctx->reconnect_in_progress = false; + LOG_INFO("Connecting to MoQ server (generation %u)", new_gen); + pthread_mutex_unlock(&ctx->mutex); +} + +// Called after session is connected successfully +static void moq_source_start_consume(struct moq_source *ctx, uint32_t expected_gen) +{ + // Check if origin is still valid and generation matches + pthread_mutex_lock(&ctx->mutex); + if (ctx->origin < 0 || ctx->generation != expected_gen) { + pthread_mutex_unlock(&ctx->mutex); + LOG_INFO("Skipping stale consume (generation mismatch or invalid origin)"); + return; + } + // Capture values while holding mutex + int32_t origin = ctx->origin; + char *broadcast_copy = bstrdup(ctx->broadcast); + pthread_mutex_unlock(&ctx->mutex); + + // Consume broadcast by path + int32_t consume = moq_origin_consume(origin, broadcast_copy, strlen(broadcast_copy)); + if (consume < 0) { + LOG_ERROR("Failed to consume broadcast '%s': %d", broadcast_copy, consume); + bfree(broadcast_copy); + // Failed to consume - clean up session/origin + pthread_mutex_lock(&ctx->mutex); + if (ctx->generation == expected_gen) { + if (ctx->session >= 0) { + moq_session_close(ctx->session); + ctx->session = -1; + } + if (ctx->origin >= 0) { + moq_origin_close(ctx->origin); + ctx->origin = -1; + } + } + pthread_mutex_unlock(&ctx->mutex); + moq_source_blank_video(ctx); + return; + } + + pthread_mutex_lock(&ctx->mutex); + // Verify generation hasn't changed while we were waiting + if (ctx->generation != expected_gen) { + pthread_mutex_unlock(&ctx->mutex); + LOG_INFO("Generation changed during consume setup, cleaning up"); + moq_consume_close(consume); + bfree(broadcast_copy); + return; + } + ctx->consume = consume; + pthread_mutex_unlock(&ctx->mutex); + + // Subscribe to catalog updates + int32_t catalog_handle = moq_consume_catalog(consume, on_catalog, ctx); + if (catalog_handle < 0) { + LOG_ERROR("Failed to subscribe to catalog for '%s': %d", broadcast_copy, catalog_handle); + bfree(broadcast_copy); + // Failed to get catalog - clean up + pthread_mutex_lock(&ctx->mutex); + if (ctx->generation == expected_gen) { + if (ctx->consume >= 0) { + moq_consume_close(ctx->consume); + ctx->consume = -1; + } + if (ctx->session >= 0) { + moq_session_close(ctx->session); + ctx->session = -1; + } + if (ctx->origin >= 0) { + moq_origin_close(ctx->origin); + ctx->origin = -1; + } + } + pthread_mutex_unlock(&ctx->mutex); + moq_source_blank_video(ctx); + return; + } + + LOG_INFO("Consuming broadcast: %s", broadcast_copy); + bfree(broadcast_copy); +} + +// NOTE: Caller must hold ctx->mutex when calling this function +static void moq_source_disconnect_locked(struct moq_source *ctx) +{ + if (ctx->video_track >= 0) { + moq_consume_video_close(ctx->video_track); + ctx->video_track = -1; + } + + if (ctx->catalog_handle >= 0) { + moq_consume_catalog_close(ctx->catalog_handle); + ctx->catalog_handle = -1; + } + + if (ctx->consume >= 0) { + moq_consume_close(ctx->consume); + ctx->consume = -1; + } + + if (ctx->session >= 0) { + moq_session_close(ctx->session); + ctx->session = -1; + } + + if (ctx->origin >= 0) { + moq_origin_close(ctx->origin); + ctx->origin = -1; + } + + moq_source_destroy_decoder_locked(ctx); + ctx->got_keyframe = false; + ctx->frames_waiting_for_keyframe = 0; + ctx->consecutive_decode_errors = 0; +} + +// Blanks the video preview by outputting a NULL frame +static void moq_source_blank_video(struct moq_source *ctx) +{ + // Passing NULL to obs_source_output_video clears the current frame + obs_source_output_video(ctx->source, NULL); + LOG_DEBUG("Video preview blanked"); +} + +static bool moq_source_init_decoder(struct moq_source *ctx, const struct moq_video_config *config) +{ + // Map codec string to FFmpeg codec ID dynamically + AVCodecID codec_id = codec_string_to_id(config->codec, config->codec_len); + if (codec_id == AV_CODEC_ID_NONE) { + // Log the codec string for debugging (may not be null-terminated) + char codec_str[64] = {0}; + size_t copy_len = config->codec_len < sizeof(codec_str) - 1 ? config->codec_len : sizeof(codec_str) - 1; + if (config->codec && copy_len > 0) { + memcpy(codec_str, config->codec, copy_len); + } + LOG_ERROR("Unknown or unsupported codec: '%s'", codec_str); + return false; + } + + // Find decoder for the codec + const AVCodec *codec = avcodec_find_decoder(codec_id); + if (!codec) { + LOG_ERROR("Decoder not found for codec ID: %d", codec_id); + return false; + } + + // Create codec context (can be done outside mutex) + AVCodecContext *new_codec_ctx = avcodec_alloc_context3(codec); + if (!new_codec_ctx) { + LOG_ERROR("Failed to allocate codec context"); + return false; + } + + // Get dimensions from config - required for buffer allocation + uint32_t width = 0; + uint32_t height = 0; + + if (config->coded_width && *config->coded_width > 0) { + new_codec_ctx->width = *config->coded_width; + width = *config->coded_width; + } + if (config->coded_height && *config->coded_height > 0) { + new_codec_ctx->height = *config->coded_height; + height = *config->coded_height; + } + + // Use codec description as extradata (contains SPS/PPS for H.264, VPS/SPS/PPS for HEVC, etc.) + if (config->description && config->description_len > 0) { + new_codec_ctx->extradata = (uint8_t *)av_mallocz(config->description_len + AV_INPUT_BUFFER_PADDING_SIZE); + if (new_codec_ctx->extradata) { + memcpy(new_codec_ctx->extradata, config->description, config->description_len); + new_codec_ctx->extradata_size = static_cast(config->description_len); + } + } + + // Open codec + if (avcodec_open2(new_codec_ctx, codec, NULL) < 0) { + LOG_ERROR("Failed to open codec"); + avcodec_free_context(&new_codec_ctx); + return false; + } + + // If dimensions weren't in config, try to get them from the opened codec context + // (may have been parsed from extradata) + if (width == 0 && new_codec_ctx->width > 0) { + width = new_codec_ctx->width; + } + if (height == 0 && new_codec_ctx->height > 0) { + height = new_codec_ctx->height; + } + + // Now take the mutex and swap in the new decoder state + pthread_mutex_lock(&ctx->mutex); + + // Destroy old decoder state + if (ctx->sws_ctx) { + sws_freeContext(ctx->sws_ctx); + } + if (ctx->codec_ctx) { + avcodec_free_context(&ctx->codec_ctx); + } + if (ctx->frame_buffer) { + bfree(ctx->frame_buffer); + } + + // Install new decoder state + // Note: sws_ctx, frame_buffer, and frame dimensions will be initialized + // dynamically on first decoded frame when we know the actual pixel format + ctx->codec_ctx = new_codec_ctx; + ctx->current_codec_id = codec_id; + ctx->current_pix_fmt = AV_PIX_FMT_NONE; // Will be set on first frame + ctx->sws_ctx = NULL; // Will be created on first frame with actual pixel format + ctx->frame_buffer = NULL; // Will be allocated on first frame with actual dimensions + ctx->frame.width = width; + ctx->frame.height = height; + ctx->frame.linesize[0] = width * 4; + ctx->frame.data[0] = NULL; + ctx->frame.format = VIDEO_FORMAT_RGBA; + ctx->frame.timestamp = 0; + ctx->got_keyframe = false; + ctx->frames_waiting_for_keyframe = 0; + ctx->consecutive_decode_errors = 0; + + pthread_mutex_unlock(&ctx->mutex); + + // Log codec name for debugging + char codec_str[64] = {0}; + size_t copy_len = config->codec_len < sizeof(codec_str) - 1 ? config->codec_len : sizeof(codec_str) - 1; + if (config->codec && copy_len > 0) { + memcpy(codec_str, config->codec, copy_len); + } + LOG_INFO("Decoder initialized: codec=%s, dimensions=%ux%u (may be refined on first frame)", + codec_str, width, height); + return true; +} + +// NOTE: Caller must hold ctx->mutex when calling this function +static void moq_source_destroy_decoder_locked(struct moq_source *ctx) +{ + if (ctx->sws_ctx) { + sws_freeContext(ctx->sws_ctx); + ctx->sws_ctx = NULL; + } + + if (ctx->codec_ctx) { + avcodec_free_context(&ctx->codec_ctx); + ctx->codec_ctx = NULL; + } + + if (ctx->frame_buffer) { + bfree(ctx->frame_buffer); + ctx->frame_buffer = NULL; + ctx->frame.data[0] = NULL; + } + + // Reset dynamic format tracking + ctx->current_codec_id = AV_CODEC_ID_NONE; + ctx->current_pix_fmt = AV_PIX_FMT_NONE; +} + +static void moq_source_decode_frame(struct moq_source *ctx, int32_t frame_id) +{ + // Fast path: check atomic flag before taking lock + if (ctx->shutting_down.load()) { + moq_consume_frame_close(frame_id); + return; + } + + pthread_mutex_lock(&ctx->mutex); + + // Double-check after acquiring lock (may have changed) + if (ctx->shutting_down.load()) { + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Check if decoder is still valid (may have been destroyed during reconnect) + // Note: sws_ctx and frame_buffer may be NULL on first frame - they're created dynamically + if (!ctx->codec_ctx) { + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Get frame data + struct moq_frame frame_data; + if (moq_consume_frame(frame_id, &frame_data) < 0) { + LOG_ERROR("Failed to get frame data"); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Skip non-keyframes until we get the first one + if (!ctx->got_keyframe && !frame_data.keyframe) { + ctx->frames_waiting_for_keyframe++; + if (ctx->frames_waiting_for_keyframe == 1 || + (ctx->frames_waiting_for_keyframe % 30) == 0) { + LOG_INFO("Waiting for keyframe... (skipped %u frames so far)", + ctx->frames_waiting_for_keyframe); + } + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Mark that we've received a keyframe from the stream + if (frame_data.keyframe) { + if (!ctx->got_keyframe) { + LOG_INFO("Got keyframe after waiting for %u frames, payload_size=%zu", + ctx->frames_waiting_for_keyframe, frame_data.payload_size); + // Flush decoder to ensure clean state when starting from keyframe + avcodec_flush_buffers(ctx->codec_ctx); + } + ctx->got_keyframe = true; + ctx->frames_waiting_for_keyframe = 0; + ctx->consecutive_decode_errors = 0; + } + + // Create AVPacket from frame data + AVPacket *packet = av_packet_alloc(); + if (!packet) { + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + packet->data = (uint8_t *)frame_data.payload; + packet->size = static_cast(frame_data.payload_size); + packet->pts = frame_data.timestamp_us / 1000; // Convert to milliseconds + packet->dts = packet->pts; + + // Send packet to decoder + int ret = avcodec_send_packet(ctx->codec_ctx, packet); + av_packet_free(&packet); + + if (ret < 0) { + if (ret != AVERROR(EAGAIN)) { + ctx->consecutive_decode_errors++; + char errbuf[AV_ERROR_MAX_STRING_SIZE]; + av_strerror(ret, errbuf, sizeof(errbuf)); + + // If too many consecutive errors, flush decoder and wait for next keyframe + if (ctx->consecutive_decode_errors >= 5) { + LOG_WARNING("Too many send errors (%u), flushing decoder and waiting for keyframe", + ctx->consecutive_decode_errors); + avcodec_flush_buffers(ctx->codec_ctx); + ctx->got_keyframe = false; + ctx->consecutive_decode_errors = 0; + } else if (ctx->consecutive_decode_errors == 1) { + LOG_ERROR("Error sending packet to decoder: %s", errbuf); + } + } + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Receive decoded frames + AVFrame *frame = av_frame_alloc(); + if (!frame) { + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + ret = avcodec_receive_frame(ctx->codec_ctx, frame); + if (ret < 0) { + if (ret != AVERROR(EAGAIN)) { + ctx->consecutive_decode_errors++; + char errbuf[AV_ERROR_MAX_STRING_SIZE]; + av_strerror(ret, errbuf, sizeof(errbuf)); + + // If too many consecutive errors, flush decoder and wait for next keyframe + if (ctx->consecutive_decode_errors >= 5) { + LOG_WARNING("Too many decode errors (%u), flushing decoder and waiting for keyframe", + ctx->consecutive_decode_errors); + avcodec_flush_buffers(ctx->codec_ctx); + ctx->got_keyframe = false; + ctx->consecutive_decode_errors = 0; + } else if (ctx->consecutive_decode_errors == 1) { + // Only log first error in a sequence + LOG_ERROR("Error receiving frame from decoder: %s", errbuf); + } + } + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Successfully decoded a frame - reset error counter + ctx->consecutive_decode_errors = 0; + + // Check if we need to (re)initialize the scaler - either first frame, dimension change, or pixel format change + enum AVPixelFormat decoded_pix_fmt = (enum AVPixelFormat)frame->format; + bool dimensions_changed = (frame->width != (int)ctx->frame.width || frame->height != (int)ctx->frame.height); + bool pix_fmt_changed = (decoded_pix_fmt != ctx->current_pix_fmt); + bool need_reinit = (!ctx->sws_ctx || !ctx->frame_buffer || dimensions_changed || pix_fmt_changed); + + if (need_reinit) { + if (dimensions_changed) { + LOG_INFO("Decoded frame dimensions changed: %ux%u -> %dx%d", + ctx->frame.width, ctx->frame.height, frame->width, frame->height); + } + if (pix_fmt_changed) { + LOG_INFO("Decoded frame pixel format changed: %d -> %d (%s)", + ctx->current_pix_fmt, decoded_pix_fmt, + av_get_pix_fmt_name(decoded_pix_fmt) ? av_get_pix_fmt_name(decoded_pix_fmt) : "unknown"); + } + + // Validate that dimensions are positive and reasonable + if (frame->width <= 0 || frame->height <= 0 || + frame->width > 16384 || frame->height > 16384) { + LOG_ERROR("Invalid decoded frame dimensions: %dx%d", frame->width, frame->height); + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Validate pixel format is supported by swscale + if (decoded_pix_fmt == AV_PIX_FMT_NONE) { + LOG_ERROR("Invalid decoded frame pixel format: %d", decoded_pix_fmt); + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Free old sws context + if (ctx->sws_ctx) { + sws_freeContext(ctx->sws_ctx); + ctx->sws_ctx = NULL; + } + + // Create new scaling context with the actual pixel format from the decoded frame + struct SwsContext *new_sws_ctx = sws_getContext( + frame->width, frame->height, decoded_pix_fmt, + frame->width, frame->height, AV_PIX_FMT_RGBA, + SWS_BILINEAR, NULL, NULL, NULL + ); + if (!new_sws_ctx) { + LOG_ERROR("Failed to create scaling context for %dx%d pix_fmt=%d (%s)", + frame->width, frame->height, decoded_pix_fmt, + av_get_pix_fmt_name(decoded_pix_fmt) ? av_get_pix_fmt_name(decoded_pix_fmt) : "unknown"); + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Reallocate frame buffer for new dimensions (width * height * 4 for RGBA) + size_t new_buffer_size = (size_t)frame->width * (size_t)frame->height * 4; + uint8_t *new_frame_buffer = (uint8_t *)bmalloc(new_buffer_size); + if (!new_frame_buffer) { + LOG_ERROR("Failed to allocate frame buffer for %dx%d (%zu bytes)", + frame->width, frame->height, new_buffer_size); + sws_freeContext(new_sws_ctx); + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); + return; + } + + // Free old frame buffer + if (ctx->frame_buffer) { + bfree(ctx->frame_buffer); + } + + // Install new state + ctx->sws_ctx = new_sws_ctx; + ctx->current_pix_fmt = decoded_pix_fmt; + ctx->frame_buffer = new_frame_buffer; + ctx->frame.width = frame->width; + ctx->frame.height = frame->height; + ctx->frame.linesize[0] = frame->width * 4; + ctx->frame.data[0] = new_frame_buffer; + + LOG_INFO("Scaler initialized for %dx%d pix_fmt=%s", + frame->width, frame->height, + av_get_pix_fmt_name(decoded_pix_fmt) ? av_get_pix_fmt_name(decoded_pix_fmt) : "unknown"); + } + + // Convert YUV420P to RGBA + uint8_t *dst_data[4] = {ctx->frame_buffer, NULL, NULL, NULL}; + int dst_linesize[4] = {static_cast(ctx->frame.width * 4), 0, 0, 0}; + + sws_scale(ctx->sws_ctx, (const uint8_t *const *)frame->data, frame->linesize, + 0, ctx->frame.height, dst_data, dst_linesize); + + // Update OBS frame timestamp and output + ctx->frame.timestamp = frame_data.timestamp_us; + obs_source_output_video(ctx->source, &ctx->frame); + + av_frame_free(&frame); + pthread_mutex_unlock(&ctx->mutex); + moq_consume_frame_close(frame_id); +} + +// Registration function +void register_moq_source() +{ + struct obs_source_info info = {}; + info.id = "moq_source"; + info.type = OBS_SOURCE_TYPE_INPUT; + info.output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_DO_NOT_DUPLICATE; + info.get_name = [](void *) -> const char * { + return "Moq Source (MoQ)"; + }; + info.create = moq_source_create; + info.destroy = moq_source_destroy; + info.update = moq_source_update; + info.get_defaults = moq_source_get_defaults; + info.get_properties = moq_source_properties; + + obs_register_source(&info); +} diff --git a/cpp/obs/src/moq-source.h b/cpp/obs/src/moq-source.h new file mode 100644 index 000000000..292fb76fd --- /dev/null +++ b/cpp/obs/src/moq-source.h @@ -0,0 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +void register_moq_source(); diff --git a/cpp/obs/src/obs-moq.cpp b/cpp/obs/src/obs-moq.cpp new file mode 100644 index 000000000..1254db76a --- /dev/null +++ b/cpp/obs/src/obs-moq.cpp @@ -0,0 +1,57 @@ +/* +SPDX-License-Identifier: GPL-2.0-or-later + +Plugin Name +Copyright (C) + +This program is free software; you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation; either version 2 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License along +with this program. If not, see +*/ + +#include + +#include "moq-output.h" +#include "moq-service.h" +#include "moq-source.h" + +#ifdef MOQ_FRONTEND_ENABLED +#include "moq-dock.h" +#endif + +extern "C" { +#include "moq.h" +} + +OBS_DECLARE_MODULE() +OBS_MODULE_USE_DEFAULT_LOCALE("obs-moq", "en-US") +MODULE_EXPORT const char *obs_module_description(void) +{ + return "OBS MoQ (Media over QUIC) module"; +} + +bool obs_module_load(void) +{ + // Use RUST_LOG env var for more verbose output + // The second argument is the string length of the first argument. + moq_log_level("info", 4); + + register_moq_output(); + register_moq_service(); + register_moq_source(); + +#ifdef MOQ_FRONTEND_ENABLED + register_moq_dock(); +#endif + + return true; +} diff --git a/doc/bin/obs.md b/doc/bin/obs.md index 3638a0284..dfe151add 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -18,9 +18,52 @@ The OBS plugin allows you to: - **Publish** directly from OBS to a MoQ relay - **Subscribe** to MoQ broadcasts as an OBS source -## Repository +It loads into a stock OBS Studio install. You no longer need to build OBS from source to use it. -The plugin is maintained in a separate repository: [moq-dev/obs](https://github.com/moq-dev/obs) +## Building + +The plugin lives in-tree under `cpp/obs/`. It links `libmoq`, which is built from the in-tree `rs/libmoq` crate via cargo (CMake's `MOQ_LOCAL` points at the repo root by default), so there is no prebuilt release to download. + +### Linux (Nix) + +`libobs`, `Qt6`, and `ffmpeg` come from the dev shell; no system packages required. + +```bash +nix develop +just obs build +``` + +### macOS + +The macOS build is fully native, **not** Nix. The build spec (`cpp/obs/buildspec.json`) downloads the prebuilt obs-deps bundle (`libobs`, `Qt6`, and `ffmpeg`) on first configure, so no Homebrew packages are needed. + +Requirements: + +- Full **Xcode** (not just the Command Line Tools): `sudo xcode-select -s /Applications/Xcode.app` +- Run **outside** the Nix dev shell. The Nix toolchain sets `DEVELOPER_DIR`/`NIX_LDFLAGS`, which break the Xcode build. If you use direnv, run from a plain terminal or `exit` the shell first. + +```bash +just obs setup # downloads obs-deps, configures via the macOS preset +just obs build +just obs run # copies the plugin into ~/Library/Application Support/obs-studio/plugins and launches OBS +``` + +### Windows + +Needs Visual Studio 2022. Run from Git Bash (for `just`); the build spec downloads obs-deps the same way as macOS. + +```bash +just obs setup +just obs build +``` + +## Releases + +The plugin statically links `libmoq`, so it ships with every libmoq release rather than on its own schedule. The [`libmoq` workflow](https://github.com/moq-dev/moq/blob/main/.github/workflows/libmoq.yml) (triggered by a `libmoq-v*` tag) rebuilds the plugin against the libmoq release it just published, then cuts a matching `obs-moq-v` release with **macOS (arm64)** and **Windows (x64)** binaries. `cpp/obs/build.sh --libmoq-release ` drives each build (it fetches the prebuilt libmoq archive, so no second cargo build). + +The archives are **unsigned**, so macOS Gatekeeper and Windows SmartScreen will warn on first load (right-click → Open on macOS). Extract the archive into your OBS plugins directory: the `.plugin` bundle on macOS, or the `obs-moq/` folder (containing `bin/64bit/` + `data/`) on Windows. + +**Linux is build-from-source for now** (see the Linux section above). A prebuilt Linux binary isn't shipped: the plugin needs ffmpeg to decode subscribed video, and a Linux build links the nix/distro ffmpeg rather than the version OBS bundles, so it wouldn't load portably. (A future native decoder via `moq-video` would remove the ffmpeg dependency and let Linux ship a binary too.) ## Usage diff --git a/flake.nix b/flake.nix index ca2712a14..1fc5c9e20 100644 --- a/flake.nix +++ b/flake.nix @@ -162,6 +162,21 @@ nixfmt ]; + # Dependencies for building the OBS plugin (`just obs build`). + # Linux-only: nixpkgs marks obs-studio broken on Darwin, so macOS + # and Windows fetch libobs/Qt6 via the OBS buildspec instead (see + # cpp/obs/buildspec.json and doc/bin/obs.md). ffmpeg + cmake come from + # rustDeps. clang-tools/gersemi back `just obs check`. + obsDeps = + with pkgs; + lib.optionals (!stdenv.isDarwin) [ + obs-studio + qt6.qtbase + ninja + clang-tools + gersemi + ]; + # Apply our overlay to get the package definitions overlayPkgs = pkgs.extend self.overlays.default; in @@ -221,7 +236,7 @@ }; devShells.default = pkgs.mkShell { - packages = rustDeps ++ jsDeps ++ pyDeps ++ cdnDeps ++ packagingDeps ++ lintDeps; + packages = rustDeps ++ jsDeps ++ pyDeps ++ cdnDeps ++ packagingDeps ++ lintDeps ++ obsDeps; # jemalloc's configure uses -O0 test builds, which conflict with # Nix's _FORTIFY_SOURCE hardening (requires -O). diff --git a/justfile b/justfile index 9770a1e41..45136d9ee 100644 --- a/justfile +++ b/justfile @@ -11,6 +11,9 @@ mod kt mod swift mod go +# OBS Studio plugin (C++). See doc/bin/obs.md. +mod obs 'cpp/obs' + # Unit tests per language (`just test`). mod test diff --git a/rs/libmoq/CMakeLists.txt b/rs/libmoq/CMakeLists.txt index be505a9ab..d93f49df9 100644 --- a/rs/libmoq/CMakeLists.txt +++ b/rs/libmoq/CMakeLists.txt @@ -57,6 +57,21 @@ if(APPLE) target_link_options(moq INTERFACE "LINKER:-framework,CoreFoundation" "LINKER:-framework,Security") endif() +# System libraries the Rust std/deps pull in on Windows (from +# `cargo rustc -- --print native-static-libs`). When the staticlib is linked +# into a C/C++ target by an external linker, cargo can't inject these itself. +if(WIN32) + target_link_libraries(moq INTERFACE + ntdll + userenv + ws2_32 + bcrypt + dbghelp + advapi32 + legacy_stdio_definitions + ) +endif() + if(BUILD_RUST_LIB) add_dependencies(moq rust_build) endif()