From 570498bd1f6c5a767395950665e07022b9c6b4f2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 17:30:52 -0700 Subject: [PATCH 01/13] feat(obs): vendor the OBS plugin in-tree under cpp/obs Bring the OBS Studio plugin (github.com/moq-dev/obs) into the monorepo as a slim copy under a new cpp/ top-level, so it builds against the in-tree libmoq instead of a downloaded release. Drops the upstream build-aux/ scripts and .github/ template CI; keeps src/, the CMake template, presets, buildspec, and locale. CMake's MOQ_LOCAL points at the repo root, so libmoq builds from rs/libmoq via cargo and links statically (no release tarball). Build wiring per platform: - Linux: the nix dev shell provides obs-studio/qt6/ffmpeg/clang-tools/gersemi (Linux-only; nixpkgs marks obs-studio broken on Darwin). `just obs build`. - macOS: native, not nix. buildspec.json downloads libobs/Qt6; ffmpeg and pkg-config come from Homebrew; needs full Xcode and must run outside the nix shell. Verified end to end here (arm64 obs-moq.plugin). - Windows: native Visual Studio 2022 + buildspec. Documented, not verified. Also makes the macOS buildspec xattr quarantine strip non-fatal (it chokes on build outputs that carry no quarantine attribute). The plugin is GPL-2.0, isolated under cpp/obs with its own LICENSE. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 4 + cpp/obs/.clang-format | 209 ++++ cpp/obs/.gersemirc | 8 + cpp/obs/.gitignore | 11 + cpp/obs/CMakeLists.txt | 119 ++ cpp/obs/CMakePresets.json | 177 +++ cpp/obs/LICENSE | 339 ++++++ cpp/obs/README.md | 22 + cpp/obs/buildspec.json | 45 + cpp/obs/cmake/common/bootstrap.cmake | 90 ++ cpp/obs/cmake/common/buildnumber.cmake | 33 + cpp/obs/cmake/common/buildspec_common.cmake | 226 ++++ cpp/obs/cmake/common/ccache.cmake | 25 + cpp/obs/cmake/common/compiler_common.cmake | 83 ++ cpp/obs/cmake/common/helpers_common.cmake | 49 + cpp/obs/cmake/common/osconfig.cmake | 20 + cpp/obs/cmake/linux/compilerconfig.cmake | 78 ++ cpp/obs/cmake/linux/defaults.cmake | 88 ++ cpp/obs/cmake/linux/helpers.cmake | 105 ++ cpp/obs/cmake/macos/buildspec.cmake | 37 + cpp/obs/cmake/macos/compilerconfig.cmake | 103 ++ cpp/obs/cmake/macos/defaults.cmake | 39 + cpp/obs/cmake/macos/helpers.cmake | 100 ++ .../macos/resources/ccache-launcher-c.in | 26 + .../macos/resources/ccache-launcher-cxx.in | 26 + .../macos/resources/create-package.cmake.in | 35 + cpp/obs/cmake/macos/resources/distribution.in | 33 + .../resources/installer-macos.pkgproj.in | 920 +++++++++++++++ cpp/obs/cmake/macos/xcode.cmake | 174 +++ cpp/obs/cmake/windows/buildspec.cmake | 24 + cpp/obs/cmake/windows/compilerconfig.cmake | 63 + cpp/obs/cmake/windows/defaults.cmake | 18 + cpp/obs/cmake/windows/helpers.cmake | 107 ++ .../cmake/windows/resources/resource.rc.in | 32 + cpp/obs/data/locale/en-US.ini | 0 cpp/obs/justfile | 89 ++ cpp/obs/src/logger.h | 8 + cpp/obs/src/moq-dock.cpp | 444 +++++++ cpp/obs/src/moq-dock.h | 66 ++ cpp/obs/src/moq-output.cpp | 365 ++++++ cpp/obs/src/moq-output.h | 51 + cpp/obs/src/moq-service.cpp | 112 ++ cpp/obs/src/moq-service.h | 19 + cpp/obs/src/moq-source.cpp | 1022 +++++++++++++++++ cpp/obs/src/moq-source.h | 3 + cpp/obs/src/obs-moq.cpp | 55 + doc/bin/obs.md | 40 +- flake.nix | 17 +- justfile | 3 + 49 files changed, 5759 insertions(+), 3 deletions(-) create mode 100644 cpp/obs/.clang-format create mode 100644 cpp/obs/.gersemirc create mode 100644 cpp/obs/.gitignore create mode 100644 cpp/obs/CMakeLists.txt create mode 100644 cpp/obs/CMakePresets.json create mode 100644 cpp/obs/LICENSE create mode 100644 cpp/obs/README.md create mode 100644 cpp/obs/buildspec.json create mode 100644 cpp/obs/cmake/common/bootstrap.cmake create mode 100644 cpp/obs/cmake/common/buildnumber.cmake create mode 100644 cpp/obs/cmake/common/buildspec_common.cmake create mode 100644 cpp/obs/cmake/common/ccache.cmake create mode 100644 cpp/obs/cmake/common/compiler_common.cmake create mode 100644 cpp/obs/cmake/common/helpers_common.cmake create mode 100644 cpp/obs/cmake/common/osconfig.cmake create mode 100644 cpp/obs/cmake/linux/compilerconfig.cmake create mode 100644 cpp/obs/cmake/linux/defaults.cmake create mode 100644 cpp/obs/cmake/linux/helpers.cmake create mode 100644 cpp/obs/cmake/macos/buildspec.cmake create mode 100644 cpp/obs/cmake/macos/compilerconfig.cmake create mode 100644 cpp/obs/cmake/macos/defaults.cmake create mode 100644 cpp/obs/cmake/macos/helpers.cmake create mode 100644 cpp/obs/cmake/macos/resources/ccache-launcher-c.in create mode 100644 cpp/obs/cmake/macos/resources/ccache-launcher-cxx.in create mode 100644 cpp/obs/cmake/macos/resources/create-package.cmake.in create mode 100644 cpp/obs/cmake/macos/resources/distribution.in create mode 100644 cpp/obs/cmake/macos/resources/installer-macos.pkgproj.in create mode 100644 cpp/obs/cmake/macos/xcode.cmake create mode 100644 cpp/obs/cmake/windows/buildspec.cmake create mode 100644 cpp/obs/cmake/windows/compilerconfig.cmake create mode 100644 cpp/obs/cmake/windows/defaults.cmake create mode 100644 cpp/obs/cmake/windows/helpers.cmake create mode 100644 cpp/obs/cmake/windows/resources/resource.rc.in create mode 100644 cpp/obs/data/locale/en-US.ini create mode 100644 cpp/obs/justfile create mode 100644 cpp/obs/src/logger.h create mode 100644 cpp/obs/src/moq-dock.cpp create mode 100644 cpp/obs/src/moq-dock.h create mode 100644 cpp/obs/src/moq-output.cpp create mode 100644 cpp/obs/src/moq-output.h create mode 100644 cpp/obs/src/moq-service.cpp create mode 100644 cpp/obs/src/moq-service.h create mode 100644 cpp/obs/src/moq-source.cpp create mode 100644 cpp/obs/src/moq-source.h create mode 100644 cpp/obs/src/obs-moq.cpp diff --git a/CLAUDE.md b/CLAUDE.md index a73d86332..c563032c1 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. + /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/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..36589e9d2 --- /dev/null +++ b/cpp/obs/CMakeLists.txt @@ -0,0 +1,119 @@ +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 + 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}) +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) + +option(MOQ_LOCAL "Path to moq repo for local development" "") + +if(MOQ_LOCAL) + 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..b47a12ad1 --- /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 Universal", + "description": "Build for macOS 12.0+ (Universal binary)", + "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 Universal CI build", + "description": "Build for macOS 12.0+ (Universal binary) 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 Universal", + "description": "macOS build for Universal architectures", + "configuration": "RelWithDebInfo" + }, + { + "name": "macos-ci", + "configurePreset": "macos-ci", + "displayName": "macOS Universal CI", + "description": "macOS CI build for Universal architectures", + "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..98bb4ad54 --- /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 (see [LICENSE](LICENSE)), separate from the rest of the +repository, because it links OBS. 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/bootstrap.cmake b/cpp/obs/cmake/common/bootstrap.cmake new file mode 100644 index 000000000..3a526a083 --- /dev/null +++ b/cpp/obs/cmake/common/bootstrap.cmake @@ -0,0 +1,90 @@ +# 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) + +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..59212c38f --- /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..87d435a01 --- /dev/null +++ b/cpp/obs/cmake/common/osconfig.cmake @@ -0,0 +1,20 @@ +# 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_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..bf00c2c19 --- /dev/null +++ b/cpp/obs/cmake/macos/defaults.cmake @@ -0,0 +1,39 @@ +# CMake macOS defaults module + +include_guard(GLOBAL) + +# Set empty codesigning team if not specified as cache variable +if(NOT CODESIGN_TEAM) + set(CODESIGN_TEAM "" CACHE STRING "OBS code signing team for macOS" FORCE) + + # Set ad-hoc codesigning identity if not specified as cache variable + if(NOT CODESIGN_IDENTITY) + set(CODESIGN_IDENTITY "-" CACHE STRING "OBS code signing identity for macOS" FORCE) + endif() +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..ab6c5f305 --- /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..b3b9dcfd8 --- /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 [[ "${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..030ee2ca2 --- /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_C_COMPILER_ID}" == "AppleClang" ]]; then + CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS},modules,clang_index_store" +fi +export CCACHE_SLOPPINESS + +if [[ "${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..abc2ce460 --- /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(${CMAKE_PROJECT_NAME} 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..6c9b5ed04 --- /dev/null +++ b/cpp/obs/justfile @@ -0,0 +1,89 @@ +#!/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 + `brew install ffmpeg pkg-config`; +# run outside `nix develop` (its toolchain breaks the Xcode build). +# `just obs setup` downloads libobs/Qt6 via buildspec.json. +# 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 + +# Repo root, passed to CMake as MOQ_LOCAL so it builds rs/libmoq from source. +moq_local := justfile_directory() / ".." / ".." + +# List all of the available commands. +default: + just --list + +# Configure via CMake presets. Override the preset or MOQ_LOCAL path if needed. +setup preset="" path=moq_local: + #!/usr/bin/env bash + set -euo pipefail + PRESET=$(just preset "{{ preset }}") + echo "Configuring with preset: $PRESET and MOQ_LOCAL={{ path }}" + cmake --preset "$PRESET" -DMOQ_LOCAL="{{ path }}" + +# Build via CMake presets (runs `just setup` first if you haven't). +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..8b06020b3 --- /dev/null +++ b/cpp/obs/src/logger.h @@ -0,0 +1,8 @@ +#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..9c4403780 --- /dev/null +++ b/cpp/obs/src/moq-dock.cpp @@ -0,0 +1,444 @@ +#include "moq-dock.h" +#include "logger.h" + +#include +#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; +} + +QString FormatDuration(int seconds) +{ + int h = seconds / 3600; + int m = (seconds % 3600) / 60; + int s = seconds % 60; + return QString::asprintf("%02d:%02d:%02d", h, m, s); +} + +// Add a "name: value" row to the stats grid and return the (right-aligned) value label. +QLabel *AddStatRow(QGridLayout *grid, int row, const QString &name) +{ + auto *nameLabel = new QLabel(name); + nameLabel->setStyleSheet("color: palette(mid);"); + auto *valueLabel = new QLabel("—"); + valueLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); + valueLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + grid->addWidget(nameLabel, row, 0); + grid->addWidget(valueLabel, row, 1); + return valueLabel; +} + +} // 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) so the inputs get the full width. + auto *form = new QFormLayout(); + form->setRowWrapPolicy(QFormLayout::WrapAllRows); + form->setContentsMargins(0, 0, 0, 0); + form->addRow("Relay URL", urlEdit); + form->addRow("Broadcast path", pathEdit); + + button = new QPushButton("Go Live", this); + button->setCursor(Qt::PointingHandCursor); + connect(button, &QPushButton::clicked, this, &MoQDock::ToggleStream); + + status = new QLabel("Idle", this); + status->setWordWrap(true); + status->setStyleSheet("color: palette(mid);"); + + auto *statsBox = new QGroupBox("Statistics", this); + auto *grid = new QGridLayout(statsBox); + grid->setColumnStretch(1, 1); + grid->setVerticalSpacing(4); + statState = AddStatRow(grid, 0, "Status"); + statDuration = AddStatRow(grid, 1, "Duration"); + statBitrate = AddStatRow(grid, 2, "Bitrate"); + statSent = AddStatRow(grid, 3, "Data sent"); + statDropped = AddStatRow(grid, 4, "Dropped frames"); + statConnect = AddStatRow(grid, 5, "Connect time"); + + auto *versionLabel = new QLabel(QString("libmoq %1").arg(MOQ_VERSION_STRING), this); + versionLabel->setAlignment(Qt::AlignRight | Qt::AlignBottom); + versionLabel->setStyleSheet("color: palette(mid); font-size: 10px;"); + + auto *layout = new QVBoxLayout(this); + layout->setSpacing(10); + layout->addLayout(form); + layout->addWidget(button); + layout->addWidget(status); + layout->addWidget(statsBox); + layout->addStretch(); + layout->addWidget(versionLabel); + + statsTimer = new QTimer(this); + statsTimer->setInterval(1000); + connect(statsTimer, &QTimer::timeout, this, &MoQDock::UpdateStats); + + 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; + } + + lastBytes = 0; + lastSample = std::chrono::steady_clock::now(); + streamStart = lastSample; + statsTimer->start(); + + SetRunning(true); + status->setText("Connecting…"); +} + +void MoQDock::StopStream() +{ + statsTimer->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("Idle"); + statState->setText("Offline"); + statState->setStyleSheet("color: palette(mid);"); + statDuration->setText("—"); + statBitrate->setText("—"); + statSent->setText("—"); + statDropped->setText("—"); + statConnect->setText("—"); + } +} + +void MoQDock::UpdateStats() +{ + if (!output || !running) + return; + + const auto now = std::chrono::steady_clock::now(); + const uint64_t bytes = obs_output_get_total_bytes(output); + const double secs = std::chrono::duration(now - lastSample).count(); + const double kbps = secs > 0.0 ? (double)(bytes - lastBytes) * 8.0 / 1000.0 / secs : 0.0; + lastBytes = bytes; + lastSample = now; + + const bool connected = obs_output_active(output) && bytes > 0; + statState->setText(connected ? "● Live" : "Connecting…"); + statState->setStyleSheet(connected ? "color: #36a45e; font-weight: bold;" : "color: palette(mid);"); + + const int liveSecs = (int)std::chrono::duration_cast(now - streamStart).count(); + statDuration->setText(FormatDuration(liveSecs)); + statBitrate->setText(QString("%1 kb/s").arg((int)(kbps + 0.5))); + statSent->setText(QString("%1 MB").arg((double)bytes / (1024.0 * 1024.0), 0, 'f', 1)); + + const int total = obs_output_get_total_frames(output); + const int dropped = obs_output_get_frames_dropped(output); + const double dropPct = total > 0 ? (double)dropped * 100.0 / (double)total : 0.0; + statDropped->setText(QString("%1 (%2%)").arg(dropped).arg(dropPct, 0, 'f', 1)); + + const int connectMs = obs_output_get_connect_time_ms(output); + statConnect->setText(connectMs > 0 ? QString("%1 ms").arg(connectMs) : "—"); + + if (connected) + status->setText("Streaming"); +} + +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..50795de91 --- /dev/null +++ b/cpp/obs/src/moq-dock.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#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 UpdateStats(); + +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; + + QLabel *statState; + QLabel *statDuration; + QLabel *statBitrate; + QLabel *statSent; + QLabel *statDropped; + QLabel *statConnect; + + QTimer *statsTimer; + + OBSServiceAutoRelease service; + OBSOutputAutoRelease output; + OBSEncoderAutoRelease videoEncoder; + OBSEncoderAutoRelease audioEncoder; + + bool running = false; + uint64_t lastBytes = 0; + std::chrono::steady_clock::time_point lastSample; + std::chrono::steady_clock::time_point streamStart; +}; + +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..e08af339f --- /dev/null +++ b/cpp/obs/src/moq-output.cpp @@ -0,0 +1,365 @@ +#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..a46e6a51b --- /dev/null +++ b/cpp/obs/src/moq-output.h @@ -0,0 +1,51 @@ +#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..69aa36ec2 --- /dev/null +++ b/cpp/obs/src/moq-service.cpp @@ -0,0 +1,112 @@ +#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..adc7175b1 --- /dev/null +++ b/cpp/obs/src/moq-service.h @@ -0,0 +1,19 @@ +#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..824b189d4 --- /dev/null +++ b/cpp/obs/src/moq-source.cpp @@ -0,0 +1,1022 @@ +#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..5396bb643 --- /dev/null +++ b/cpp/obs/src/moq-source.h @@ -0,0 +1,3 @@ +#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..678f81506 --- /dev/null +++ b/cpp/obs/src/obs-moq.cpp @@ -0,0 +1,55 @@ +/* +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..8edb2f7ae 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -18,9 +18,45 @@ 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 prebuilt `libobs` and `Qt6` on first configure, but `ffmpeg` and `pkg-config` come from Homebrew. + +Requirements: + +- Full **Xcode** (not just the Command Line Tools): `sudo xcode-select -s /Applications/Xcode.app` +- `brew install ffmpeg pkg-config` +- 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 +``` ## Usage diff --git a/flake.nix b/flake.nix index ca2712a14..a525fa087 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 + # 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 From 5fa55ade166e64a0807643c7dda0f74c6dd5347f Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 17:54:29 -0700 Subject: [PATCH 02/13] feat(obs): simplify the MoQ dock (upstream obs#42) Port github.com/moq-dev/obs#42 into the vendored copy: - Full-width URL and Broadcast-name fields (QFormLayout::AllNonFixedFieldsGrow). - Rename "Broadcast path" to "Broadcast name". - Drop the Statistics box (it showed OBS output counters, not libmoq data) in favor of a Disconnected / Connecting / Connected indicator driven by the session-connect state libmoq reports. - Use concrete stylesheet colors; Qt style sheets ignore the palette() function. Co-Authored-By: Claude Opus 4.8 --- cpp/obs/src/moq-dock.cpp | 110 +++++++++------------------------------ cpp/obs/src/moq-dock.h | 16 +----- 2 files changed, 28 insertions(+), 98 deletions(-) diff --git a/cpp/obs/src/moq-dock.cpp b/cpp/obs/src/moq-dock.cpp index 9c4403780..dda20ddaa 100644 --- a/cpp/obs/src/moq-dock.cpp +++ b/cpp/obs/src/moq-dock.cpp @@ -6,12 +6,11 @@ #include #include -#include #include -#include #include #include #include +#include #include #include #include @@ -72,27 +71,6 @@ std::string SettingsPath() return s; } -QString FormatDuration(int seconds) -{ - int h = seconds / 3600; - int m = (seconds % 3600) / 60; - int s = seconds % 60; - return QString::asprintf("%02d:%02d:%02d", h, m, s); -} - -// Add a "name: value" row to the stats grid and return the (right-aligned) value label. -QLabel *AddStatRow(QGridLayout *grid, int row, const QString &name) -{ - auto *nameLabel = new QLabel(name); - nameLabel->setStyleSheet("color: palette(mid);"); - auto *valueLabel = new QLabel("—"); - valueLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - valueLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); - grid->addWidget(nameLabel, row, 0); - grid->addWidget(valueLabel, row, 1); - return valueLabel; -} - } // namespace MoQDock::MoQDock(QWidget *parent) : QWidget(parent) @@ -105,48 +83,40 @@ MoQDock::MoQDock(QWidget *parent) : QWidget(parent) pathEdit->setText("obs"); pathEdit->setPlaceholderText("(optional) broadcast name"); - // Labels above the fields (WrapAllRows) so the inputs get the full width. + // 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 path", pathEdit); + 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("Idle", this); + status = new QLabel(this); status->setWordWrap(true); - status->setStyleSheet("color: palette(mid);"); - - auto *statsBox = new QGroupBox("Statistics", this); - auto *grid = new QGridLayout(statsBox); - grid->setColumnStretch(1, 1); - grid->setVerticalSpacing(4); - statState = AddStatRow(grid, 0, "Status"); - statDuration = AddStatRow(grid, 1, "Duration"); - statBitrate = AddStatRow(grid, 2, "Bitrate"); - statSent = AddStatRow(grid, 3, "Data sent"); - statDropped = AddStatRow(grid, 4, "Dropped frames"); - statConnect = AddStatRow(grid, 5, "Connect time"); + 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: palette(mid); font-size: 10px;"); + 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->addWidget(statsBox); layout->addStretch(); layout->addWidget(versionLabel); - statsTimer = new QTimer(this); - statsTimer->setInterval(1000); - connect(statsTimer, &QTimer::timeout, this, &MoQDock::UpdateStats); + 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); @@ -301,18 +271,16 @@ void MoQDock::StartStream() return; } - lastBytes = 0; - lastSample = std::chrono::steady_clock::now(); - streamStart = lastSample; - statsTimer->start(); + pollTimer->start(); SetRunning(true); - status->setText("Connecting…"); + status->setText("● Connecting…"); + status->setStyleSheet("color: #d08b1d;"); } void MoQDock::StopStream() { - statsTimer->stop(); + pollTimer->stop(); if (output) { signal_handler_disconnect(obs_output_get_signal_handler(output), "stop", OnOutputStopped, this); @@ -342,48 +310,22 @@ void MoQDock::SetRunning(bool isRunning) pathEdit->setEnabled(!isRunning); if (!isRunning) { - status->setText("Idle"); - statState->setText("Offline"); - statState->setStyleSheet("color: palette(mid);"); - statDuration->setText("—"); - statBitrate->setText("—"); - statSent->setText("—"); - statDropped->setText("—"); - statConnect->setText("—"); + status->setText("● Disconnected"); + status->setStyleSheet("color: #888888;"); } } -void MoQDock::UpdateStats() +void MoQDock::UpdateStatus() { if (!output || !running) return; - const auto now = std::chrono::steady_clock::now(); - const uint64_t bytes = obs_output_get_total_bytes(output); - const double secs = std::chrono::duration(now - lastSample).count(); - const double kbps = secs > 0.0 ? (double)(bytes - lastBytes) * 8.0 / 1000.0 / secs : 0.0; - lastBytes = bytes; - lastSample = now; - - const bool connected = obs_output_active(output) && bytes > 0; - statState->setText(connected ? "● Live" : "Connecting…"); - statState->setStyleSheet(connected ? "color: #36a45e; font-weight: bold;" : "color: palette(mid);"); - - const int liveSecs = (int)std::chrono::duration_cast(now - streamStart).count(); - statDuration->setText(FormatDuration(liveSecs)); - statBitrate->setText(QString("%1 kb/s").arg((int)(kbps + 0.5))); - statSent->setText(QString("%1 MB").arg((double)bytes / (1024.0 * 1024.0), 0, 'f', 1)); - - const int total = obs_output_get_total_frames(output); - const int dropped = obs_output_get_frames_dropped(output); - const double dropPct = total > 0 ? (double)dropped * 100.0 / (double)total : 0.0; - statDropped->setText(QString("%1 (%2%)").arg(dropped).arg(dropPct, 0, 'f', 1)); - - const int connectMs = obs_output_get_connect_time_ms(output); - statConnect->setText(connectMs > 0 ? QString("%1 ms").arg(connectMs) : "—"); - - if (connected) - status->setText("Streaming"); + // 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() diff --git a/cpp/obs/src/moq-dock.h b/cpp/obs/src/moq-dock.h index 50795de91..a6d12ae5b 100644 --- a/cpp/obs/src/moq-dock.h +++ b/cpp/obs/src/moq-dock.h @@ -3,8 +3,6 @@ #include #include -#include - class QLineEdit; class QPushButton; class QLabel; @@ -23,7 +21,7 @@ class MoQDock : public QWidget { private slots: void ToggleStream(); - void UpdateStats(); + void UpdateStatus(); private: void StartStream(); @@ -43,14 +41,7 @@ private slots: QPushButton *button; QLabel *status; - QLabel *statState; - QLabel *statDuration; - QLabel *statBitrate; - QLabel *statSent; - QLabel *statDropped; - QLabel *statConnect; - - QTimer *statsTimer; + QTimer *pollTimer; OBSServiceAutoRelease service; OBSOutputAutoRelease output; @@ -58,9 +49,6 @@ private slots: OBSEncoderAutoRelease audioEncoder; bool running = false; - uint64_t lastBytes = 0; - std::chrono::steady_clock::time_point lastSample; - std::chrono::steady_clock::time_point streamStart; }; void register_moq_dock(); From 6a9e22867acfda540dc123c5de4b31b0174ab903 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 18:00:33 -0700 Subject: [PATCH 03/13] docs(obs): make the GPL boundary explicit (SPDX + README) The OBS plugin links libobs (GPL-2.0, no linking exception), so it must be GPL. That copyleft is confined to cpp/obs: it's a separately-distributable work, and per GPLv2 its presence here is mere aggregation that doesn't affect the MIT/Apache licensing of the moq crates (libmoq stays permissive). Make that legible to humans and license scanners: - SPDX-License-Identifier: GPL-2.0-or-later on every cpp/obs source file. - A licensing exception note in the root README. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 ++ cpp/obs/src/logger.h | 1 + cpp/obs/src/moq-dock.cpp | 1 + cpp/obs/src/moq-dock.h | 1 + cpp/obs/src/moq-output.cpp | 1 + cpp/obs/src/moq-output.h | 1 + cpp/obs/src/moq-service.cpp | 1 + cpp/obs/src/moq-service.h | 1 + cpp/obs/src/moq-source.cpp | 1 + cpp/obs/src/moq-source.h | 1 + cpp/obs/src/obs-moq.cpp | 2 ++ 11 files changed, 13 insertions(+) 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/src/logger.h b/cpp/obs/src/logger.h index 8b06020b3..d34c9ecc2 100644 --- a/cpp/obs/src/logger.h +++ b/cpp/obs/src/logger.h @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-or-later #include // Logging macros - use MOQ_ prefix to avoid conflicts with OBS log level constants diff --git a/cpp/obs/src/moq-dock.cpp b/cpp/obs/src/moq-dock.cpp index dda20ddaa..2714611aa 100644 --- a/cpp/obs/src/moq-dock.cpp +++ b/cpp/obs/src/moq-dock.cpp @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-or-later #include "moq-dock.h" #include "logger.h" diff --git a/cpp/obs/src/moq-dock.h b/cpp/obs/src/moq-dock.h index a6d12ae5b..aef15d870 100644 --- a/cpp/obs/src/moq-dock.h +++ b/cpp/obs/src/moq-dock.h @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-or-later #pragma once #include diff --git a/cpp/obs/src/moq-output.cpp b/cpp/obs/src/moq-output.cpp index e08af339f..4695eb31a 100644 --- a/cpp/obs/src/moq-output.cpp +++ b/cpp/obs/src/moq-output.cpp @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-or-later #include #include "moq-output.h" diff --git a/cpp/obs/src/moq-output.h b/cpp/obs/src/moq-output.h index a46e6a51b..486e1e2ed 100644 --- a/cpp/obs/src/moq-output.h +++ b/cpp/obs/src/moq-output.h @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-or-later #pragma once #include diff --git a/cpp/obs/src/moq-service.cpp b/cpp/obs/src/moq-service.cpp index 69aa36ec2..367e3734f 100644 --- a/cpp/obs/src/moq-service.cpp +++ b/cpp/obs/src/moq-service.cpp @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-or-later #include "moq-service.h" // TODO: Define supported codecs. diff --git a/cpp/obs/src/moq-service.h b/cpp/obs/src/moq-service.h index adc7175b1..2c910a032 100644 --- a/cpp/obs/src/moq-service.h +++ b/cpp/obs/src/moq-service.h @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-or-later #pragma once #include #include diff --git a/cpp/obs/src/moq-source.cpp b/cpp/obs/src/moq-source.cpp index 824b189d4..74a3c795b 100644 --- a/cpp/obs/src/moq-source.cpp +++ b/cpp/obs/src/moq-source.cpp @@ -1,3 +1,4 @@ +// SPDX-License-Identifier: GPL-2.0-or-later #include #include #include diff --git a/cpp/obs/src/moq-source.h b/cpp/obs/src/moq-source.h index 5396bb643..292fb76fd 100644 --- a/cpp/obs/src/moq-source.h +++ b/cpp/obs/src/moq-source.h @@ -1,3 +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 index 678f81506..1254db76a 100644 --- a/cpp/obs/src/obs-moq.cpp +++ b/cpp/obs/src/obs-moq.cpp @@ -1,4 +1,6 @@ /* +SPDX-License-Identifier: GPL-2.0-or-later + Plugin Name Copyright (C) From ef0c5de2ae6260bb680f756fc5d73561a05aff3c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 18:08:52 -0700 Subject: [PATCH 04/13] fix(obs): address CodeRabbit review on the build/CMake layer Build/CMake fixes (macOS build re-verified end to end): - CMakeLists: MOQ_LOCAL now defaults to the in-tree repo root (two levels up) with an EXISTS guard and a quoted add_subdirectory path, so a bare `cmake --preset` also links in-tree libmoq, matching the docs. Falls back to the release download only when rs/libmoq is absent. - buildspec_common: drop single quotes from the -D args passed via execute_process (no shell, so they were passed literally), un-corrupting CMAKE_OSX_ARCHITECTURES and CMAKE_PREFIX_PATH for the OBS-sources sub-build. - osconfig: set CMAKE_C_EXTENSIONS FALSE on Linux/BSD to match macOS/Windows. - macos/defaults: initialize CODESIGN_IDENTITY independently of CODESIGN_TEAM. - macos/helpers: fix dSYM bundle casing (.dsym -> .dSYM). - windows/helpers: attach the generated .rc to the function's ${target}. - ccache launchers: POSIX `[ ]` tests (scripts are #!/bin/sh), and use CMAKE_CXX_COMPILER_ID in the C++ launcher (was a C copy-paste). - CMakePresets: macOS preset labels say arm64, not "Universal" (arch is arm64). - justfile: correct the build recipe comment (it does not auto-run setup). Co-Authored-By: Claude Opus 4.8 --- cpp/obs/CMakeLists.txt | 9 ++++++--- cpp/obs/CMakePresets.json | 16 ++++++++-------- cpp/obs/cmake/common/buildspec_common.cmake | 4 ++-- cpp/obs/cmake/common/osconfig.cmake | 1 + cpp/obs/cmake/macos/defaults.cmake | 12 +++++++----- cpp/obs/cmake/macos/helpers.cmake | 2 +- .../cmake/macos/resources/ccache-launcher-c.in | 6 +++--- .../cmake/macos/resources/ccache-launcher-cxx.in | 6 +++--- cpp/obs/cmake/windows/helpers.cmake | 2 +- cpp/obs/justfile | 3 ++- 10 files changed, 34 insertions(+), 27 deletions(-) diff --git a/cpp/obs/CMakeLists.txt b/cpp/obs/CMakeLists.txt index 36589e9d2..04e9a8fe5 100644 --- a/cpp/obs/CMakeLists.txt +++ b/cpp/obs/CMakeLists.txt @@ -28,10 +28,13 @@ endif() target_link_libraries(obs-moq PRIVATE OBS::libobs) -option(MOQ_LOCAL "Path to moq repo for local development" "") +# 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) - add_subdirectory(${MOQ_LOCAL}/rs/libmoq moq) +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) diff --git a/cpp/obs/CMakePresets.json b/cpp/obs/CMakePresets.json index b47a12ad1..c2eb5554e 100644 --- a/cpp/obs/CMakePresets.json +++ b/cpp/obs/CMakePresets.json @@ -20,8 +20,8 @@ }, { "name": "macos", - "displayName": "macOS Universal", - "description": "Build for macOS 12.0+ (Universal binary)", + "displayName": "macOS arm64", + "description": "Build for macOS 12.0+ (arm64)", "inherits": [ "template" ], @@ -49,8 +49,8 @@ "inherits": [ "macos" ], - "displayName": "macOS Universal CI build", - "description": "Build for macOS 12.0+ (Universal binary) for CI", + "displayName": "macOS arm64 CI build", + "description": "Build for macOS 12.0+ (arm64) for CI", "generator": "Xcode", "cacheVariables": { "CMAKE_COMPILE_WARNING_AS_ERROR": true, @@ -134,15 +134,15 @@ { "name": "macos", "configurePreset": "macos", - "displayName": "macOS Universal", - "description": "macOS build for Universal architectures", + "displayName": "macOS arm64", + "description": "macOS build for arm64", "configuration": "RelWithDebInfo" }, { "name": "macos-ci", "configurePreset": "macos-ci", - "displayName": "macOS Universal CI", - "description": "macOS CI build for Universal architectures", + "displayName": "macOS arm64 CI", + "description": "macOS CI build for arm64", "configuration": "RelWithDebInfo" }, { diff --git a/cpp/obs/cmake/common/buildspec_common.cmake b/cpp/obs/cmake/common/buildspec_common.cmake index 59212c38f..0ed0246b6 100644 --- a/cpp/obs/cmake/common/buildspec_common.cmake +++ b/cpp/obs/cmake/common/buildspec_common.cmake @@ -58,7 +58,7 @@ function(_setup_obs_studio) 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_arch "-DCMAKE_OSX_ARCHITECTURES:STRING=arm64;x86_64") set(_cmake_extra "-DCMAKE_OSX_DEPLOYMENT_TARGET=${CMAKE_OSX_DEPLOYMENT_TARGET}") endif() @@ -68,7 +68,7 @@ function(_setup_obs_studio) "${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} + -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 diff --git a/cpp/obs/cmake/common/osconfig.cmake b/cpp/obs/cmake/common/osconfig.cmake index 87d435a01..a34b161f1 100644 --- a/cpp/obs/cmake/common/osconfig.cmake +++ b/cpp/obs/cmake/common/osconfig.cmake @@ -13,6 +13,7 @@ elseif(CMAKE_HOST_SYSTEM_NAME STREQUAL "Darwin") 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) diff --git a/cpp/obs/cmake/macos/defaults.cmake b/cpp/obs/cmake/macos/defaults.cmake index bf00c2c19..b3b33c033 100644 --- a/cpp/obs/cmake/macos/defaults.cmake +++ b/cpp/obs/cmake/macos/defaults.cmake @@ -3,13 +3,15 @@ include_guard(GLOBAL) # Set empty codesigning team if not specified as cache variable -if(NOT CODESIGN_TEAM) +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 - if(NOT CODESIGN_IDENTITY) - set(CODESIGN_IDENTITY "-" CACHE STRING "OBS code signing identity 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) diff --git a/cpp/obs/cmake/macos/helpers.cmake b/cpp/obs/cmake/macos/helpers.cmake index ab6c5f305..20d809cc9 100644 --- a/cpp/obs/cmake/macos/helpers.cmake +++ b/cpp/obs/cmake/macos/helpers.cmake @@ -65,7 +65,7 @@ function(set_target_properties_plugin target) 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) + 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) diff --git a/cpp/obs/cmake/macos/resources/ccache-launcher-c.in b/cpp/obs/cmake/macos/resources/ccache-launcher-c.in index b3b9dcfd8..1283d673e 100644 --- a/cpp/obs/cmake/macos/resources/ccache-launcher-c.in +++ b/cpp/obs/cmake/macos/resources/ccache-launcher-c.in @@ -1,6 +1,6 @@ #!/bin/sh -if [[ "$1" == "${CMAKE_C_COMPILER}" ]] ; then +if [ "$1" = "${CMAKE_C_COMPILER}" ]; then shift fi @@ -15,12 +15,12 @@ export CCACHE_COMPILERCHECK='content' CCACHE_SLOPPINESS='file_stat_matches,include_file_mtime,include_file_ctime,system_headers' -if [[ "${CMAKE_C_COMPILER_ID}" == "AppleClang" ]]; then +if [ "${CMAKE_C_COMPILER_ID}" = "AppleClang" ]; then CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS},modules,clang_index_store" fi export CCACHE_SLOPPINESS -if [[ "${CI}" ]]; then +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 index 030ee2ca2..f94d1d74d 100644 --- a/cpp/obs/cmake/macos/resources/ccache-launcher-cxx.in +++ b/cpp/obs/cmake/macos/resources/ccache-launcher-cxx.in @@ -1,6 +1,6 @@ #!/bin/sh -if [[ "$1" == "${CMAKE_CXX_COMPILER}" ]] ; then +if [ "$1" = "${CMAKE_CXX_COMPILER}" ]; then shift fi @@ -15,12 +15,12 @@ export CCACHE_COMPILERCHECK='content' CCACHE_SLOPPINESS='file_stat_matches,include_file_mtime,include_file_ctime,system_headers' -if [[ "${CMAKE_C_COMPILER_ID}" == "AppleClang" ]]; then +if [ "${CMAKE_CXX_COMPILER_ID}" = "AppleClang" ]; then CCACHE_SLOPPINESS="${CCACHE_SLOPPINESS},modules,clang_index_store" fi export CCACHE_SLOPPINESS -if [[ "${CI}" ]]; then +if [ -n "${CI}" ]; then export CCACHE_NOHASHDIR=true fi exec "${CMAKE_CXX_COMPILER_LAUNCHER}" "${CMAKE_CXX_COMPILER}" "$@" diff --git a/cpp/obs/cmake/windows/helpers.cmake b/cpp/obs/cmake/windows/helpers.cmake index abc2ce460..437537961 100644 --- a/cpp/obs/cmake/windows/helpers.cmake +++ b/cpp/obs/cmake/windows/helpers.cmake @@ -55,7 +55,7 @@ function(set_target_properties_plugin target) 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(${CMAKE_PROJECT_NAME} PRIVATE "${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 diff --git a/cpp/obs/justfile b/cpp/obs/justfile index 6c9b5ed04..207f8eab2 100644 --- a/cpp/obs/justfile +++ b/cpp/obs/justfile @@ -28,7 +28,8 @@ setup preset="" path=moq_local: echo "Configuring with preset: $PRESET and MOQ_LOCAL={{ path }}" cmake --preset "$PRESET" -DMOQ_LOCAL="{{ path }}" -# Build via CMake presets (runs `just setup` first if you haven't). +# 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 From 63cbf9842f822f47561aed56a7a228b9178f4b3b Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 18:35:32 -0700 Subject: [PATCH 05/13] fix(obs): don't compute MOQ_LOCAL in the justfile `just obs setup` (module invocation from the repo root) resolves `justfile_directory()` to the ROOT justfile's directory, not cpp/obs, so `justfile_directory()/../..` overshot the repo root and MOQ_LOCAL pointed outside the tree. CMake then fell back to FetchContent and downloaded the x86_64 libmoq release, which fails to link on an aarch64 host ("Relocations in generic ELF (EM: 62) ... file in wrong format"). Drop the path computation and let CMakeLists.txt default MOQ_LOCAL to ${CMAKE_CURRENT_SOURCE_DIR}/../.. (reliable regardless of how the recipe is invoked). `setup` only passes -DMOQ_LOCAL when given an explicit override. Verified on an aarch64 Linux + Nix host: builds the in-tree rs/libmoq and links obs-moq.so against nix obs-studio/Qt6/ffmpeg. Co-Authored-By: Claude Opus 4.8 --- cpp/obs/justfile | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/cpp/obs/justfile b/cpp/obs/justfile index 207f8eab2..6e1a9b555 100644 --- a/cpp/obs/justfile +++ b/cpp/obs/justfile @@ -13,20 +13,26 @@ set quiet -# Repo root, passed to CMake as MOQ_LOCAL so it builds rs/libmoq from source. -moq_local := justfile_directory() / ".." / ".." - # List all of the available commands. default: just --list -# Configure via CMake presets. Override the preset or MOQ_LOCAL path if needed. -setup preset="" path=moq_local: +# 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 }}") - echo "Configuring with preset: $PRESET and MOQ_LOCAL={{ path }}" - cmake --preset "$PRESET" -DMOQ_LOCAL="{{ path }}" + 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. From 7fc44d3fa18401f31838048bd4fd635dccca0531 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 19:23:04 -0700 Subject: [PATCH 06/13] ci(obs): add tag-triggered package/release workflow Mirrors libmoq.yml / moq-gst.yml: pushing an `obs-moq-v*` tag builds the plugin per platform and attaches archives to a GitHub release via the shared .github/scripts/release.sh helper. - cpp/obs/build.sh: configures + builds via the platform CMake preset and packages the result. macOS ships the .plugin bundle (zip); Linux/Windows ship the OBS portable layout obs-moq/bin/64bit/ + data (tar.gz / zip). Uses `cmake -E tar` so no zip/gtar dependency on the Windows runner. - .github/workflows/obs.yml: matrix of linux x86_64/aarch64 (Nix devshell), macOS arm64 and Windows x64 (native Xcode/VS + buildspec, no Nix). Archives are unsigned (no signing secrets); release notes/docs say so. Verified end to end: macOS arm64 (this build) and Linux aarch64 (OCI A1 via nix develop) produce loadable archives. Windows leg is written to the same pattern but not yet exercised by a real tag. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/obs.yml | 116 ++++++++++++++++++++++++++++++++++++++ cpp/obs/build.sh | 106 ++++++++++++++++++++++++++++++++++ doc/bin/obs.md | 6 ++ 3 files changed, 228 insertions(+) create mode 100644 .github/workflows/obs.yml create mode 100755 cpp/obs/build.sh diff --git a/.github/workflows/obs.yml b/.github/workflows/obs.yml new file mode 100644 index 000000000..b68c894f4 --- /dev/null +++ b/.github/workflows/obs.yml @@ -0,0 +1,116 @@ +name: obs + +on: + push: + tags: + - "obs-moq-v*" + +permissions: + contents: read + +jobs: + build: + name: Build (${{ matrix.target }}) + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + include: + # Linux builds inside the nix dev shell (obs-studio/Qt6/ffmpeg come + # from nixpkgs). macOS and Windows are native: full Xcode / Visual + # Studio, with libobs/Qt6 downloaded by buildspec.json at configure. + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + use_nix: true + - target: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + use_nix: true + - target: aarch64-apple-darwin + os: macos-latest + use_nix: false + - target: x86_64-pc-windows-msvc + os: windows-latest + use_nix: false + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install Nix + if: matrix.use_nix + uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + with: + determinate: false + + # obs-deps ship libobs + Qt6 but not ffmpeg/pkg-config; Homebrew does. + - name: Install macOS deps + if: runner.os == 'macOS' + run: brew install ffmpeg pkg-config + + - name: Parse version + id: parse + shell: bash + run: .github/scripts/release.sh parse-version obs-moq + + - name: Build and package (Linux, nix) + if: matrix.use_nix + shell: bash + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ steps.parse.outputs.version }} + run: | + nix develop --accept-flake-config --command \ + ./cpp/obs/build.sh --target "$TARGET" --version "$VERSION" --output dist + + - name: Build and package (macOS / Windows, native) + if: '!matrix.use_nix' + shell: bash + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ steps.parse.outputs.version }} + run: | + ./cpp/obs/build.sh --target "$TARGET" --version "$VERSION" --output dist + + - name: Upload artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: obs-moq-${{ matrix.target }} + path: dist/* + + release: + name: Release + needs: 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 obs-moq + + - name: Find previous tag + id: prev_tag + run: .github/scripts/release.sh prev-tag obs-moq + + - name: Download artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 + with: + path: artifacts + merge-multiple: true + + - name: Create or update release + env: + GH_TOKEN: ${{ github.token }} + RELEASE_TAG: ${{ github.ref_name }} + 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/cpp/obs/build.sh b/cpp/obs/build.sh new file mode 100755 index 000000000..6b9ecee6e --- /dev/null +++ b/cpp/obs/build.sh @@ -0,0 +1,106 @@ +#!/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 + `brew install ffmpeg pkg-config`, run OUTSIDE nix +# (libobs/Qt6 are 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" + +while [[ $# -gt 0 ]]; do + case $1 in + --target) TARGET="$2"; shift 2 ;; + --version) VERSION="$2"; shift 2 ;; + --output) OUTPUT_DIR="$2"; shift 2 ;; + -h | --help) + echo "Usage: $0 [--target TARGET] [--version VERSION] [--output DIR]" + exit 0 + ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +if [[ -z "$TARGET" ]]; then + TARGET=$(cc -dumpmachine 2>/dev/null || echo unknown) + echo "Detected target: $TARGET" +fi + +# Default the version from buildspec.json (the plugin's source of truth). +if [[ -z "$VERSION" ]]; then + VERSION=$(grep -E '"version"' "$SCRIPT_DIR/buildspec.json" | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\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 + +# Homebrew ffmpeg/pkg-config on macOS (obs-deps ships libobs/Qt6, not ffmpeg). +if [[ "$KIND" == "macos" ]] && command -v brew >/dev/null; then + brew_prefix="$(brew --prefix)" + export PKG_CONFIG_PATH="$brew_prefix/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" +fi + +# 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)..." +cmake --preset "$PRESET" +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/doc/bin/obs.md b/doc/bin/obs.md index 8edb2f7ae..7732f7483 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -58,6 +58,12 @@ just obs setup just obs build ``` +## Releases + +Pushing an `obs-moq-v*` tag runs [`.github/workflows/obs.yml`](https://github.com/moq-dev/moq/blob/main/.github/workflows/obs.yml): it builds on Linux (x86_64 + arm64, via Nix), macOS (arm64), and Windows (x64), then attaches per-platform archives to a GitHub release. `cpp/obs/build.sh` drives the per-platform build and packaging. + +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 Linux/Windows. + ## Usage ### Publishing From e2a7d576855fb3e974f5293777162d355e5b05fa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 19:25:45 -0700 Subject: [PATCH 07/13] style(obs): shfmt build.sh Co-Authored-By: Claude Opus 4.8 --- cpp/obs/build.sh | 75 +++++++++++++++++++++++++++++++++++------------- 1 file changed, 55 insertions(+), 20 deletions(-) diff --git a/cpp/obs/build.sh b/cpp/obs/build.sh index 6b9ecee6e..9b1d1c3fb 100755 --- a/cpp/obs/build.sh +++ b/cpp/obs/build.sh @@ -23,14 +23,26 @@ OUTPUT_DIR="dist" while [[ $# -gt 0 ]]; do case $1 in - --target) TARGET="$2"; shift 2 ;; - --version) VERSION="$2"; shift 2 ;; - --output) OUTPUT_DIR="$2"; shift 2 ;; + --target) + TARGET="$2" + shift 2 + ;; + --version) + VERSION="$2" + shift 2 + ;; + --output) + OUTPUT_DIR="$2" + shift 2 + ;; -h | --help) echo "Usage: $0 [--target TARGET] [--version VERSION] [--output DIR]" exit 0 ;; - *) echo "Unknown option: $1" >&2; exit 1 ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; esac done @@ -47,10 +59,25 @@ 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 ;; + *-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 # Homebrew ffmpeg/pkg-config on macOS (obs-deps ships libobs/Qt6, not ffmpeg). @@ -77,13 +104,19 @@ 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; } + [[ -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; } + [[ -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/" @@ -94,13 +127,15 @@ 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" ) +( + 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" +) From 3edbfa54ec06e2649a354de0d5aae565537702b8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 19:51:46 -0700 Subject: [PATCH 08/13] fix(obs): build the OBS plugin on Windows The Windows build was documented but never run. Two CMake gaps blocked it: - FFmpeg discovery used pkg_check_modules, but the Windows obs-deps ship FFmpeg without pkg-config .pc files. Use find_package(FFmpeg) on Windows via a vendored copy of OBS's FindFFmpeg finder, which searches the obs-deps prefix directly. pkg-config stays the path on macOS/Linux. - libmoq's static lib did not propagate the Windows system libraries Rust std pulls in (ntdll, userenv, ws2_32, bcrypt, dbghelp, advapi32, legacy_stdio_definitions), so linking moq.lib into the C++ target failed with unresolved __imp_NtCreateNamedPipeFile. Verified: builds with VS 2022 Build Tools + Windows SDK 10.0.22621 and loads into a stock OBS Studio install. Co-Authored-By: Claude Opus 4.8 --- cpp/obs/CMakeLists.txt | 22 +- cpp/obs/cmake/common/FindFFmpeg.cmake | 366 ++++++++++++++++++++++++++ rs/libmoq/CMakeLists.txt | 15 ++ 3 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 cpp/obs/cmake/common/FindFFmpeg.cmake diff --git a/cpp/obs/CMakeLists.txt b/cpp/obs/CMakeLists.txt index 04e9a8fe5..324adb468 100644 --- a/cpp/obs/CMakeLists.txt +++ b/cpp/obs/CMakeLists.txt @@ -15,12 +15,22 @@ add_library(obs-moq MODULE) if(${BUILD_PLUGIN}) find_package(libobs REQUIRED) - # FFmpeg dependency - 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}) + # FFmpeg dependency. The Windows obs-deps ship FFmpeg without pkg-config .pc + # files, so fall back to the vendored FindFFmpeg finder (it searches the + # obs-deps prefix directly). pkg-config is the simplest path on macOS/Linux. + if(WIN32) + 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) 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/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() From 52a2127d3f72786678579f96002e75107a578913 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 19:59:01 -0700 Subject: [PATCH 09/13] ci(obs): release the plugin from libmoq.yml against the prebuilt libmoq The OBS plugin statically links libmoq, so its meaningful version is the libmoq version and it should ship with every libmoq release rather than on a separate manual tag that drifts out of date. Fold the plugin build into libmoq.yml: after the libmoq `release` job publishes the per-target archives, `obs-build` rebuilds the plugin against that just-published release (CMake's FetchContent path) and `obs-release` cuts a parallel obs-moq-v release. This reuses the exact libmoq.a that was released (guaranteed-matching ABI), needs no second cargo build, and drops the rust toolchain requirement from the macOS/Windows plugin jobs. - cpp/obs/build.sh: add --libmoq-release VERSION (forces CMake's release download via empty MOQ_LOCAL + MOQ_VERSION/MOQ_TARGET; versions the plugin to match). Default/local builds still use the in-tree rs/libmoq. - Remove the standalone obs.yml; obs-build/obs-release live in libmoq.yml. Verified: build.sh --libmoq-release 0.3.6 on macOS arm64 fetches the published libmoq release and links it with no rust in the environment (in-tree libmoq is 0.3.6, so the C ABI matches). Co-Authored-By: Claude Opus 4.8 --- .github/workflows/libmoq.yml | 109 ++++++++++++++++++++++++++++++++ .github/workflows/obs.yml | 116 ----------------------------------- cpp/obs/build.sh | 26 +++++++- doc/bin/obs.md | 2 +- 4 files changed, 134 insertions(+), 119 deletions(-) delete mode 100644 .github/workflows/obs.yml diff --git a/.github/workflows/libmoq.yml b/.github/workflows/libmoq.yml index f59c4404f..daf94dd2e 100644 --- a/.github/workflows/libmoq.yml +++ b/.github/workflows/libmoq.yml @@ -108,3 +108,112 @@ 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 }} + + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + use_nix: true + - target: aarch64-unknown-linux-gnu + os: ubuntu-24.04-arm + use_nix: true + - target: aarch64-apple-darwin + os: macos-latest + use_nix: false + - target: x86_64-pc-windows-msvc + os: windows-latest + use_nix: false + + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Install Nix + if: matrix.use_nix + uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main + with: + determinate: false + + # obs-deps ship libobs + Qt6 but not ffmpeg/pkg-config; Homebrew does. + - name: Install macOS deps + if: runner.os == 'macOS' + run: brew install ffmpeg pkg-config + + - name: Parse version + id: parse + shell: bash + run: .github/scripts/release.sh parse-version libmoq + + - name: Build and package (Linux, nix) + if: matrix.use_nix + shell: bash + env: + TARGET: ${{ matrix.target }} + VERSION: ${{ steps.parse.outputs.version }} + run: | + nix develop --accept-flake-config --command \ + ./cpp/obs/build.sh --target "$TARGET" --libmoq-release "$VERSION" --output dist + + - name: Build and package (macOS / Windows, native) + if: '!matrix.use_nix' + 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 + + # Cut a parallel obs-moq-v release at this commit (gh creates + # the tag). Notes auto-generate against the previous obs-moq release. + - 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 }}" + run: .github/scripts/release.sh create artifacts diff --git a/.github/workflows/obs.yml b/.github/workflows/obs.yml deleted file mode 100644 index b68c894f4..000000000 --- a/.github/workflows/obs.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: obs - -on: - push: - tags: - - "obs-moq-v*" - -permissions: - contents: read - -jobs: - build: - name: Build (${{ matrix.target }}) - runs-on: ${{ matrix.os }} - - strategy: - fail-fast: false - matrix: - include: - # Linux builds inside the nix dev shell (obs-studio/Qt6/ffmpeg come - # from nixpkgs). macOS and Windows are native: full Xcode / Visual - # Studio, with libobs/Qt6 downloaded by buildspec.json at configure. - - target: x86_64-unknown-linux-gnu - os: ubuntu-latest - use_nix: true - - target: aarch64-unknown-linux-gnu - os: ubuntu-24.04-arm - use_nix: true - - target: aarch64-apple-darwin - os: macos-latest - use_nix: false - - target: x86_64-pc-windows-msvc - os: windows-latest - use_nix: false - - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - with: - fetch-depth: 0 - persist-credentials: false - - - name: Install Nix - if: matrix.use_nix - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main - with: - determinate: false - - # obs-deps ship libobs + Qt6 but not ffmpeg/pkg-config; Homebrew does. - - name: Install macOS deps - if: runner.os == 'macOS' - run: brew install ffmpeg pkg-config - - - name: Parse version - id: parse - shell: bash - run: .github/scripts/release.sh parse-version obs-moq - - - name: Build and package (Linux, nix) - if: matrix.use_nix - shell: bash - env: - TARGET: ${{ matrix.target }} - VERSION: ${{ steps.parse.outputs.version }} - run: | - nix develop --accept-flake-config --command \ - ./cpp/obs/build.sh --target "$TARGET" --version "$VERSION" --output dist - - - name: Build and package (macOS / Windows, native) - if: '!matrix.use_nix' - shell: bash - env: - TARGET: ${{ matrix.target }} - VERSION: ${{ steps.parse.outputs.version }} - run: | - ./cpp/obs/build.sh --target "$TARGET" --version "$VERSION" --output dist - - - name: Upload artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: obs-moq-${{ matrix.target }} - path: dist/* - - release: - name: Release - needs: 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 obs-moq - - - name: Find previous tag - id: prev_tag - run: .github/scripts/release.sh prev-tag obs-moq - - - name: Download artifacts - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 - with: - path: artifacts - merge-multiple: true - - - name: Create or update release - env: - GH_TOKEN: ${{ github.token }} - RELEASE_TAG: ${{ github.ref_name }} - 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/cpp/obs/build.sh b/cpp/obs/build.sh index 9b1d1c3fb..3a624b72c 100755 --- a/cpp/obs/build.sh +++ b/cpp/obs/build.sh @@ -20,6 +20,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGET="" VERSION="" OUTPUT_DIR="dist" +MOQ_RELEASE="" while [[ $# -gt 0 ]]; do case $1 in @@ -35,8 +36,16 @@ while [[ $# -gt 0 ]]; do 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]" + echo "Usage: $0 [--target TARGET] [--version VERSION] [--output DIR] [--libmoq-release VERSION]" exit 0 ;; *) @@ -46,6 +55,11 @@ while [[ $# -gt 0 ]]; do 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" @@ -93,7 +107,15 @@ OUTPUT_DIR="$(cd "$OUTPUT_DIR" && pwd)" cd "$SCRIPT_DIR" echo "Building obs-moq $VERSION for $TARGET (preset: $PRESET)..." -cmake --preset "$PRESET" +CONFIGURE_ARGS=() +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}" diff --git a/doc/bin/obs.md b/doc/bin/obs.md index 7732f7483..519ef85b6 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -60,7 +60,7 @@ just obs build ## Releases -Pushing an `obs-moq-v*` tag runs [`.github/workflows/obs.yml`](https://github.com/moq-dev/moq/blob/main/.github/workflows/obs.yml): it builds on Linux (x86_64 + arm64, via Nix), macOS (arm64), and Windows (x64), then attaches per-platform archives to a GitHub release. `cpp/obs/build.sh` drives the per-platform build and packaging. +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 for Linux (x86_64 + arm64, via Nix), macOS (arm64), and Windows (x64). `cpp/obs/build.sh --libmoq-release ` drives each per-platform 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 Linux/Windows. From a7cb1794987bb8493676a28936f5eb6ac25c9cfa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 20:07:23 -0700 Subject: [PATCH 10/13] feat(obs): version the plugin to match the libmoq it links The plugin's compiled-in version came from buildspec.json's placeholder 0.0.1. Since it statically links libmoq and now releases per libmoq version, stamp that version into the plugin metadata too. - bootstrap.cmake: honor a PLUGIN_VERSION_OVERRIDE cache var (applied before PLUGIN_VERSION_* are derived, so project version, macOS Info.plist and the Windows resource all pick it up). - build.sh: pass -DPLUGIN_VERSION_OVERRIDE=$VERSION. In --libmoq-release mode that's the libmoq version. Also fix the buildspec version auto-default, which matched the first "version" (nested obs-studio dep) instead of the top-level plugin version. Verified on macOS arm64: --libmoq-release 0.3.6 yields a .plugin with CFBundleShortVersionString=0.3.6. Co-Authored-By: Claude Opus 4.8 --- cpp/obs/build.sh | 10 ++++++++-- cpp/obs/cmake/common/bootstrap.cmake | 7 +++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/cpp/obs/build.sh b/cpp/obs/build.sh index 3a624b72c..ad6dbc08e 100755 --- a/cpp/obs/build.sh +++ b/cpp/obs/build.sh @@ -65,9 +65,10 @@ if [[ -z "$TARGET" ]]; then echo "Detected target: $TARGET" fi -# Default the version from buildspec.json (the plugin's source of truth). +# 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 '"version"' "$SCRIPT_DIR/buildspec.json" | head -1 | sed 's/.*"version": *"\([^"]*\)".*/\1/') + VERSION=$(grep -E '^[[:space:]]{4}"version"' "$SCRIPT_DIR/buildspec.json" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/') echo "Detected version: $VERSION" fi @@ -108,6 +109,11 @@ 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 diff --git a/cpp/obs/cmake/common/bootstrap.cmake b/cpp/obs/cmake/common/bootstrap.cmake index 3a526a083..8d4db05f3 100644 --- a/cpp/obs/cmake/common/bootstrap.cmake +++ b/cpp/obs/cmake/common/bootstrap.cmake @@ -52,6 +52,13 @@ 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}) From f3d56fcd3e87c727e2204104991368b103ed7bce Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 21:36:58 -0700 Subject: [PATCH 11/13] fix(obs): correct obs release notes + license/path doc nits (self-review) - libmoq.yml obs-release: the prev-tag step was missing, and release.sh prev-tag can't help (the trigger is a libmoq-v* tag, not obs-moq-v*), so release notes would span from the libmoq tag. Compute the newest existing obs-moq tag inline and pass it as RELEASE_PREV_TAG. - Align license to GPL-2.0-or-later (matches the SPDX headers + root README) in cpp/obs/README.md and CLAUDE.md. - flake.nix: fix stale comment path obs/ -> cpp/obs/. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/libmoq.yml | 16 +++++++++++++++- CLAUDE.md | 2 +- cpp/obs/README.md | 2 +- flake.nix | 2 +- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/.github/workflows/libmoq.yml b/.github/workflows/libmoq.yml index daf94dd2e..c20b7f170 100644 --- a/.github/workflows/libmoq.yml +++ b/.github/workflows/libmoq.yml @@ -209,11 +209,25 @@ jobs: 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). Notes auto-generate against the previous obs-moq release. + # 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 c563032c1..9102162d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,7 +89,7 @@ Key architectural rule: The CDN/relay does not know anything about media. Anythi # 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. + 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) diff --git a/cpp/obs/README.md b/cpp/obs/README.md index 98bb4ad54..2c16160fe 100644 --- a/cpp/obs/README.md +++ b/cpp/obs/README.md @@ -18,5 +18,5 @@ just obs setup just obs build ``` -Licensed under GPL-2.0 (see [LICENSE](LICENSE)), separate from the rest of the +Licensed under GPL-2.0-or-later (see [LICENSE](LICENSE)), separate from the rest of the repository, because it links OBS. diff --git a/flake.nix b/flake.nix index a525fa087..1fc5c9e20 100644 --- a/flake.nix +++ b/flake.nix @@ -165,7 +165,7 @@ # 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 - # obs/buildspec.json and doc/bin/obs.md). ffmpeg + cmake come from + # cpp/obs/buildspec.json and doc/bin/obs.md). ffmpeg + cmake come from # rustDeps. clang-tools/gersemi back `just obs check`. obsDeps = with pkgs; From 5ce28aabea7fdf348f7a36bc5d7468cf0bc274e4 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 21:49:03 -0700 Subject: [PATCH 12/13] fix(obs): link the obs-deps ffmpeg on macOS, drop the Homebrew dependency The macOS plugin linked Homebrew ffmpeg at an absolute /opt/homebrew path (ffmpeg 8 / libavcodec.62), so the artifact wouldn't load without that exact brew install. obs-deps actually bundles ffmpeg on macOS too (libavcodec.61 = ffmpeg 7, the version OBS ships) -- it just lacks pkg-config .pc files, which is why the build had reached for brew. Use the vendored FindFFmpeg finder on macOS as well as Windows (it searches the obs-deps prefix directly), so the plugin links @rpath/libavcodec.dylib = the ffmpeg OBS bundles. Removes the brew install from build.sh, the workflow, the justfile, and doc/bin/obs.md. Verified on macOS arm64 with no Homebrew on PATH: links @rpath/libavcodec.dylib (compat 61), not /opt/homebrew. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/libmoq.yml | 5 ----- cpp/obs/CMakeLists.txt | 10 ++++++---- cpp/obs/build.sh | 10 ++-------- cpp/obs/justfile | 6 +++--- doc/bin/obs.md | 3 +-- 5 files changed, 12 insertions(+), 22 deletions(-) diff --git a/.github/workflows/libmoq.yml b/.github/workflows/libmoq.yml index c20b7f170..ed3c20d30 100644 --- a/.github/workflows/libmoq.yml +++ b/.github/workflows/libmoq.yml @@ -148,11 +148,6 @@ jobs: with: determinate: false - # obs-deps ship libobs + Qt6 but not ffmpeg/pkg-config; Homebrew does. - - name: Install macOS deps - if: runner.os == 'macOS' - run: brew install ffmpeg pkg-config - - name: Parse version id: parse shell: bash diff --git a/cpp/obs/CMakeLists.txt b/cpp/obs/CMakeLists.txt index 324adb468..753113036 100644 --- a/cpp/obs/CMakeLists.txt +++ b/cpp/obs/CMakeLists.txt @@ -15,10 +15,12 @@ add_library(obs-moq MODULE) if(${BUILD_PLUGIN}) find_package(libobs REQUIRED) - # FFmpeg dependency. The Windows obs-deps ship FFmpeg without pkg-config .pc - # files, so fall back to the vendored FindFFmpeg finder (it searches the - # obs-deps prefix directly). pkg-config is the simplest path on macOS/Linux. - if(WIN32) + # 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 diff --git a/cpp/obs/build.sh b/cpp/obs/build.sh index ad6dbc08e..4aac4c214 100755 --- a/cpp/obs/build.sh +++ b/cpp/obs/build.sh @@ -7,8 +7,8 @@ set -euo pipefail # 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 + `brew install ffmpeg pkg-config`, run OUTSIDE nix -# (libobs/Qt6 are downloaded by buildspec.json at configure time) +# 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) # @@ -95,12 +95,6 @@ case "$TARGET" in ;; esac -# Homebrew ffmpeg/pkg-config on macOS (obs-deps ships libobs/Qt6, not ffmpeg). -if [[ "$KIND" == "macos" ]] && command -v brew >/dev/null; then - brew_prefix="$(brew --prefix)" - export PKG_CONFIG_PATH="$brew_prefix/lib/pkgconfig${PKG_CONFIG_PATH:+:$PKG_CONFIG_PATH}" -fi - # 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" diff --git a/cpp/obs/justfile b/cpp/obs/justfile index 6e1a9b555..1abe47d85 100644 --- a/cpp/obs/justfile +++ b/cpp/obs/justfile @@ -3,9 +3,9 @@ # 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 + `brew install ffmpeg pkg-config`; -# run outside `nix develop` (its toolchain breaks the Xcode build). -# `just obs setup` downloads libobs/Qt6 via buildspec.json. +# 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 diff --git a/doc/bin/obs.md b/doc/bin/obs.md index 519ef85b6..98c0543b0 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -35,12 +35,11 @@ just obs build ### macOS -The macOS build is fully native, **not** Nix. The build spec (`cpp/obs/buildspec.json`) downloads prebuilt `libobs` and `Qt6` on first configure, but `ffmpeg` and `pkg-config` come from Homebrew. +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` -- `brew install ffmpeg pkg-config` - 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 From d5eef88be98909ad3f1a16f252d1180226818da0 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 18 Jun 2026 21:57:11 -0700 Subject: [PATCH 13/13] ci(obs): ship macOS + Windows binaries only; Linux is build-from-source The Linux plugin would link the nix/distro ffmpeg rather than the version OBS bundles, so it isn't portable. macOS and Windows link the obs-deps ffmpeg OBS ships, so those binaries are portable. Drop the Linux legs from the release matrix (no second cargo build / nix step needed now either) and document Linux as build-from-source. A future native decoder via moq-video would remove the ffmpeg dependency and let Linux ship a binary too. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/libmoq.yml | 33 +++++++-------------------------- doc/bin/obs.md | 6 ++++-- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/.github/workflows/libmoq.yml b/.github/workflows/libmoq.yml index ed3c20d30..af9bd4aa0 100644 --- a/.github/workflows/libmoq.yml +++ b/.github/workflows/libmoq.yml @@ -119,22 +119,18 @@ jobs: 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: x86_64-unknown-linux-gnu - os: ubuntu-latest - use_nix: true - - target: aarch64-unknown-linux-gnu - os: ubuntu-24.04-arm - use_nix: true - target: aarch64-apple-darwin os: macos-latest - use_nix: false - target: x86_64-pc-windows-msvc os: windows-latest - use_nix: false steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -142,29 +138,14 @@ jobs: fetch-depth: 0 persist-credentials: false - - name: Install Nix - if: matrix.use_nix - uses: DeterminateSystems/nix-installer-action@1d87d45818068401a10cf16bdc5f00b24994a83f # main - with: - determinate: false - - name: Parse version id: parse shell: bash run: .github/scripts/release.sh parse-version libmoq - - name: Build and package (Linux, nix) - if: matrix.use_nix - shell: bash - env: - TARGET: ${{ matrix.target }} - VERSION: ${{ steps.parse.outputs.version }} - run: | - nix develop --accept-flake-config --command \ - ./cpp/obs/build.sh --target "$TARGET" --libmoq-release "$VERSION" --output dist - - - name: Build and package (macOS / Windows, native) - if: '!matrix.use_nix' + # 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 }} diff --git a/doc/bin/obs.md b/doc/bin/obs.md index 98c0543b0..dfe151add 100644 --- a/doc/bin/obs.md +++ b/doc/bin/obs.md @@ -59,9 +59,11 @@ 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 for Linux (x86_64 + arm64, via Nix), macOS (arm64), and Windows (x64). `cpp/obs/build.sh --libmoq-release ` drives each per-platform build (it fetches the prebuilt libmoq archive, so no second cargo build). +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 Linux/Windows. +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