├── .circleci └── config.yml ├── .credo.exs ├── .editorconfig ├── .formatter.exs ├── .github └── workflows │ ├── fetch_changes.yml │ └── on_pr_opened.yaml ├── .gitignore ├── LICENSE ├── README.md ├── bin ├── boombox └── boombox_local ├── boombox_examples_data ├── hls.html ├── talk_to_llm.html ├── webrtc_from_browser.html ├── webrtc_to_browser.html └── whip.html ├── build_binary.sh ├── config └── config.exs ├── examples.livemd ├── lib ├── boombox.ex └── boombox │ ├── application.ex │ ├── bin.ex │ ├── internal_bin.ex │ ├── internal_bin │ ├── elixir_stream.ex │ ├── elixir_stream │ │ ├── sink.ex │ │ └── source.ex │ ├── hls.ex │ ├── pad.ex │ ├── rtmp.ex │ ├── rtp.ex │ ├── rtsp.ex │ ├── storage_endpoints.ex │ ├── storage_endpoints │ │ ├── aac.ex │ │ ├── h264.ex │ │ ├── h265.ex │ │ ├── ivf.ex │ │ ├── mp3.ex │ │ ├── mp4.ex │ │ ├── ogg.ex │ │ └── wav.ex │ └── webrtc.ex │ ├── packet.ex │ ├── pipeline.ex │ ├── server.ex │ └── utils │ ├── burrito_app.ex │ └── cli.ex ├── mix.exs ├── mix.lock └── test ├── boombox_bin_test.exs ├── boombox_storage_endpoints_test.exs ├── boombox_test.exs ├── boombox_utils_cli_test.exs ├── browser_test.exs ├── fixtures ├── bun10s.mp4 ├── bun10s_a.mp4 ├── bun10s_h265.mp4 ├── bun10s_v.mp4 ├── logo.png ├── ref_bouncing_bubble.mp4 ├── ref_bun.pcm ├── ref_bun10s_aac.mp4 ├── ref_bun10s_aac2.mp4 ├── ref_bun10s_aac_hls │ ├── g3cFdmlkZW8.m3u8 │ ├── index.m3u8 │ ├── muxed_header_g3cFdmlkZW8_part_0.mp4 │ ├── muxed_segment_0_g3cFdmlkZW8.m4s │ ├── muxed_segment_1_g3cFdmlkZW8.m4s │ └── muxed_segment_2_g3cFdmlkZW8.m4s ├── ref_bun10s_h265.mp4 ├── ref_bun10s_opus2_aac.mp4 ├── ref_bun10s_opus_aac.mp4 ├── ref_bun_imgs.mp4 ├── ref_bun_rotated.mp4 ├── sherlock.mp4 └── storage_endpoints │ ├── bun10s.aac │ ├── bun10s.h264 │ ├── bun10s.h265 │ ├── bun10s.ivf │ ├── bun10s.mp3 │ ├── bun10s.mp4 │ ├── bun10s.ogg │ ├── bun10s.wav │ └── bun10s_video.msr ├── rtp └── config_parsing_test.exs ├── support ├── async.ex ├── compare.ex ├── hls │ └── http_adaptive_stream_source.ex └── rtsp │ └── server │ ├── handler.ex │ └── pipeline.ex └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | elixir: membraneframework/elixir@1 4 | 5 | jobs: 6 | test_exclude_browser: 7 | docker: 8 | - image: membraneframeworklabs/docker_membrane 9 | environment: 10 | MIX_ENV: test 11 | 12 | working_directory: ~/app 13 | steps: 14 | - attach_workspace: 15 | at: . 16 | - checkout 17 | - run: mix deps.get 18 | - run: mix compile 19 | - run: mix test --exclude browser 20 | 21 | workflows: 22 | version: 2 23 | build: 24 | jobs: 25 | - elixir/build_test: 26 | filters: &filters 27 | tags: 28 | only: /v.*/ 29 | - elixir/lint: 30 | filters: 31 | <<: *filters 32 | - test_exclude_browser: 33 | filters: 34 | <<: *filters 35 | - elixir/hex_publish: 36 | requires: 37 | - elixir/build_test 38 | - test_exclude_browser 39 | - elixir/lint 40 | context: 41 | - Deployment 42 | filters: 43 | branches: 44 | ignore: /.*/ 45 | tags: 46 | only: /v.*/ 47 | -------------------------------------------------------------------------------- /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any config using `mix credo -C `. If no config name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: [ 25 | "lib/", 26 | "src/", 27 | "test/", 28 | "web/", 29 | "apps/*/lib/", 30 | "apps/*/src/", 31 | "apps/*/test/", 32 | "apps/*/web/" 33 | ], 34 | excluded: [~r"/_build/", ~r"/deps/", ~r"/node_modules/"] 35 | }, 36 | # 37 | # Load and configure plugins here: 38 | # 39 | plugins: [], 40 | # 41 | # If you create your own checks, you must specify the source files for 42 | # them here, so they can be loaded by Credo before running the analysis. 43 | # 44 | requires: [], 45 | # 46 | # If you want to enforce a style guide and need a more traditional linting 47 | # experience, you can change `strict` to `true` below: 48 | # 49 | strict: false, 50 | # 51 | # To modify the timeout for parsing files, change this value: 52 | # 53 | parse_timeout: 5000, 54 | # 55 | # If you want to use uncolored output by default, you can change `color` 56 | # to `false` below: 57 | # 58 | color: true, 59 | # 60 | # You can customize the parameters of any check by adding a second element 61 | # to the tuple. 62 | # 63 | # To disable a check put `false` as second element: 64 | # 65 | # {Credo.Check.Design.DuplicatedCode, false} 66 | # 67 | checks: [ 68 | # 69 | ## Consistency Checks 70 | # 71 | {Credo.Check.Consistency.ExceptionNames, []}, 72 | {Credo.Check.Consistency.LineEndings, []}, 73 | {Credo.Check.Consistency.ParameterPatternMatching, []}, 74 | {Credo.Check.Consistency.SpaceAroundOperators, []}, 75 | {Credo.Check.Consistency.SpaceInParentheses, []}, 76 | {Credo.Check.Consistency.TabsOrSpaces, []}, 77 | 78 | # 79 | ## Design Checks 80 | # 81 | # You can customize the priority of any check 82 | # Priority values are: `low, normal, high, higher` 83 | # 84 | {Credo.Check.Design.AliasUsage, 85 | [priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]}, 86 | # You can also customize the exit_status of each check. 87 | # If you don't want TODO comments to cause `mix credo` to fail, just 88 | # set this value to 0 (zero). 89 | # 90 | {Credo.Check.Design.TagTODO, [exit_status: 0]}, 91 | {Credo.Check.Design.TagFIXME, []}, 92 | 93 | # 94 | ## Readability Checks 95 | # 96 | {Credo.Check.Readability.AliasOrder, [priority: :normal]}, 97 | {Credo.Check.Readability.FunctionNames, []}, 98 | {Credo.Check.Readability.LargeNumbers, []}, 99 | {Credo.Check.Readability.MaxLineLength, [priority: :low, max_length: 120]}, 100 | {Credo.Check.Readability.ModuleAttributeNames, []}, 101 | {Credo.Check.Readability.ModuleDoc, []}, 102 | {Credo.Check.Readability.ModuleNames, []}, 103 | {Credo.Check.Readability.ParenthesesInCondition, []}, 104 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs, parens: true}, 105 | {Credo.Check.Readability.PredicateFunctionNames, []}, 106 | {Credo.Check.Readability.PreferImplicitTry, []}, 107 | {Credo.Check.Readability.RedundantBlankLines, []}, 108 | {Credo.Check.Readability.Semicolons, []}, 109 | {Credo.Check.Readability.SpaceAfterCommas, []}, 110 | {Credo.Check.Readability.StringSigils, []}, 111 | {Credo.Check.Readability.TrailingBlankLine, []}, 112 | {Credo.Check.Readability.TrailingWhiteSpace, []}, 113 | {Credo.Check.Readability.UnnecessaryAliasExpansion, []}, 114 | {Credo.Check.Readability.VariableNames, []}, 115 | {Credo.Check.Readability.WithSingleClause, false}, 116 | 117 | # 118 | ## Refactoring Opportunities 119 | # 120 | {Credo.Check.Refactor.CondStatements, []}, 121 | {Credo.Check.Refactor.CyclomaticComplexity, []}, 122 | {Credo.Check.Refactor.FunctionArity, []}, 123 | {Credo.Check.Refactor.LongQuoteBlocks, []}, 124 | {Credo.Check.Refactor.MapInto, false}, 125 | {Credo.Check.Refactor.MatchInCondition, []}, 126 | {Credo.Check.Refactor.NegatedConditionsInUnless, []}, 127 | {Credo.Check.Refactor.NegatedConditionsWithElse, []}, 128 | {Credo.Check.Refactor.Nesting, []}, 129 | {Credo.Check.Refactor.UnlessWithElse, []}, 130 | {Credo.Check.Refactor.WithClauses, []}, 131 | 132 | # 133 | ## Warnings 134 | # 135 | {Credo.Check.Warning.BoolOperationOnSameValues, []}, 136 | {Credo.Check.Warning.ExpensiveEmptyEnumCheck, []}, 137 | {Credo.Check.Warning.IExPry, []}, 138 | {Credo.Check.Warning.IoInspect, []}, 139 | {Credo.Check.Warning.LazyLogging, false}, 140 | {Credo.Check.Warning.MixEnv, []}, 141 | {Credo.Check.Warning.OperationOnSameValues, []}, 142 | {Credo.Check.Warning.OperationWithConstantResult, []}, 143 | {Credo.Check.Warning.RaiseInsideRescue, []}, 144 | {Credo.Check.Warning.UnusedEnumOperation, []}, 145 | {Credo.Check.Warning.UnusedFileOperation, []}, 146 | {Credo.Check.Warning.UnusedKeywordOperation, []}, 147 | {Credo.Check.Warning.UnusedListOperation, []}, 148 | {Credo.Check.Warning.UnusedPathOperation, []}, 149 | {Credo.Check.Warning.UnusedRegexOperation, []}, 150 | {Credo.Check.Warning.UnusedStringOperation, []}, 151 | {Credo.Check.Warning.UnusedTupleOperation, []}, 152 | {Credo.Check.Warning.UnsafeExec, []}, 153 | 154 | # 155 | # Checks scheduled for next check update (opt-in for now, just replace `false` with `[]`) 156 | 157 | # 158 | # Controversial and experimental checks (opt-in, just replace `false` with `[]`) 159 | # 160 | {Credo.Check.Readability.StrictModuleLayout, 161 | priority: :normal, order: ~w/shortdoc moduledoc behaviour use import require alias/a}, 162 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 163 | {Credo.Check.Consistency.UnusedVariableNames, force: :meaningful}, 164 | {Credo.Check.Design.DuplicatedCode, false}, 165 | {Credo.Check.Readability.AliasAs, false}, 166 | {Credo.Check.Readability.MultiAlias, false}, 167 | {Credo.Check.Readability.Specs, []}, 168 | {Credo.Check.Readability.SinglePipe, false}, 169 | {Credo.Check.Readability.WithCustomTaggedTuple, false}, 170 | {Credo.Check.Refactor.ABCSize, false}, 171 | {Credo.Check.Refactor.AppendSingleItem, false}, 172 | {Credo.Check.Refactor.DoubleBooleanNegation, false}, 173 | {Credo.Check.Refactor.ModuleDependencies, false}, 174 | {Credo.Check.Refactor.NegatedIsNil, false}, 175 | {Credo.Check.Refactor.PipeChainStart, false}, 176 | {Credo.Check.Refactor.VariableRebinding, false}, 177 | {Credo.Check.Warning.LeakyEnvironment, false}, 178 | {Credo.Check.Warning.MapGetUnsafePass, false}, 179 | {Credo.Check.Warning.UnsafeToAtom, false} 180 | 181 | # 182 | # Custom checks can be created using `mix credo.gen.check`. 183 | # 184 | ] 185 | } 186 | ] 187 | } 188 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | max_line_length = 100 10 | tab_width = 2 11 | trim_trailing_whitespace = true 12 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | inputs = [ 2 | "{lib,test,config}/**/*.{ex,exs}", 3 | ".formatter.exs", 4 | "*.exs" 5 | ] 6 | 7 | [ 8 | inputs: inputs ++ Enum.map(inputs, &"boombox_burrito/#{&1}"), 9 | import_deps: [:membrane_core] 10 | ] 11 | -------------------------------------------------------------------------------- /.github/workflows/fetch_changes.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Fetch changes 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Trigger thrice a day 8 | schedule: 9 | - cron: '0 4,8,12 * * *' 10 | 11 | # Allows you to run this workflow manually from the Actions tab 12 | workflow_dispatch: 13 | 14 | jobs: 15 | # This workflow contains a single job called "build" 16 | build: 17 | # The type of runner that the job will run on 18 | runs-on: ubuntu-latest 19 | 20 | # Steps represent a sequence of tasks that will be executed as part of the job 21 | steps: 22 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 23 | - uses: actions/checkout@v3 24 | with: 25 | fetch-depth: '0' 26 | 27 | - name: webfactory/ssh-agent 28 | uses: webfactory/ssh-agent@v0.5.4 29 | with: 30 | ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }} 31 | 32 | # Runs a set of commands using the runners shell 33 | - name: Add remote 34 | run: | 35 | git remote add source git@github.com:membraneframework/boombox.git 36 | git remote update 37 | 38 | echo "CURRENT_BRANCH=$(git branch --show-current)" >> $GITHUB_ENV 39 | 40 | - name: Check changes 41 | run: | 42 | echo ${{env.CURRENT_BRANCH}} 43 | echo "LOG_SIZE=$(git log origin/${{ env.CURRENT_BRANCH }}..source/${{ env.CURRENT_BRANCH }} | wc -l)" 44 | 45 | echo "LOG_SIZE=$(git log origin/${{ env.CURRENT_BRANCH }}..source/${{ env.CURRENT_BRANCH }} | wc -l)" >> $GITHUB_ENV 46 | 47 | - if: ${{ env.LOG_SIZE != '0'}} 48 | name: Merge changes 49 | run: | 50 | git config --global user.email "admin@membraneframework.com" 51 | git config --global user.name "MembraneFramework" 52 | 53 | git merge source/master 54 | git push origin master 55 | -------------------------------------------------------------------------------- /.github/workflows/on_pr_opened.yaml: -------------------------------------------------------------------------------- 1 | name: Add PR to Smackore project board, if the author is from outside Membrane Team 2 | on: 3 | pull_request_target: 4 | types: 5 | - opened 6 | jobs: 7 | maybe_add_to_project_board: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout membrane_core 11 | uses: actions/checkout@v3 12 | with: 13 | repository: membraneframework/membrane_core 14 | - name: Puts PR in "New PRs by community" column in the Smackore project, if the author is from outside Membrane Team 15 | uses: ./.github/actions/add_pr_to_smackore_board 16 | with: 17 | GITHUB_TOKEN: ${{ secrets.MEMBRANEFRAMEWORKADMIN_TOKEN }} 18 | AUTHOR_LOGIN: ${{ github.event.pull_request.user.login }} 19 | PR_URL: ${{ github.event.pull_request.html_url }} 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /boombox 2 | compile_commands.json 3 | .gdb_history 4 | bundlex.sh 5 | bundlex.bat 6 | 7 | # Dir generated by tmp_dir ExUnit tag 8 | /tmp/ 9 | 10 | # Created by https://www.gitignore.io/api/c,vim,linux,macos,elixir,windows,visualstudiocode 11 | # Edit at https://www.gitignore.io/?templates=c,vim,linux,macos,elixir,windows,visualstudiocode 12 | 13 | ### C ### 14 | # Prerequisites 15 | *.d 16 | 17 | # Object files 18 | *.o 19 | *.ko 20 | *.obj 21 | *.elf 22 | 23 | # Linker output 24 | *.ilk 25 | *.map 26 | *.exp 27 | 28 | # Precompiled Headers 29 | *.gch 30 | *.pch 31 | 32 | # Libraries 33 | *.lib 34 | *.a 35 | *.la 36 | *.lo 37 | 38 | # Shared objects (inc. Windows DLLs) 39 | *.dll 40 | *.so 41 | *.so.* 42 | *.dylib 43 | 44 | # Executables 45 | *.exe 46 | *.out 47 | *.app 48 | *.i*86 49 | *.x86_64 50 | *.hex 51 | 52 | # Debug files 53 | *.dSYM/ 54 | *.su 55 | *.idb 56 | *.pdb 57 | 58 | # Kernel Module Compile Results 59 | *.mod* 60 | *.cmd 61 | .tmp_versions/ 62 | modules.order 63 | Module.symvers 64 | Mkfile.old 65 | dkms.conf 66 | 67 | ### Elixir ### 68 | /_build 69 | /cover 70 | /deps 71 | /doc 72 | /.fetch 73 | erl_crash.dump 74 | *.ez 75 | *.beam 76 | /config/*.secret.exs 77 | .elixir_ls/ 78 | 79 | ### Elixir Patch ### 80 | 81 | ### Linux ### 82 | *~ 83 | 84 | # temporary files which can be created if a process still has a handle open of a deleted file 85 | .fuse_hidden* 86 | 87 | # KDE directory preferences 88 | .directory 89 | 90 | # Linux trash folder which might appear on any partition or disk 91 | .Trash-* 92 | 93 | # .nfs files are created when an open file is removed but is still being accessed 94 | .nfs* 95 | 96 | ### macOS ### 97 | # General 98 | .DS_Store 99 | .AppleDouble 100 | .LSOverride 101 | 102 | # Icon must end with two \r 103 | Icon 104 | 105 | # Thumbnails 106 | ._* 107 | 108 | # Files that might appear in the root of a volume 109 | .DocumentRevisions-V100 110 | .fseventsd 111 | .Spotlight-V100 112 | .TemporaryItems 113 | .Trashes 114 | .VolumeIcon.icns 115 | .com.apple.timemachine.donotpresent 116 | 117 | # Directories potentially created on remote AFP share 118 | .AppleDB 119 | .AppleDesktop 120 | Network Trash Folder 121 | Temporary Items 122 | .apdisk 123 | 124 | ### Vim ### 125 | # Swap 126 | [._]*.s[a-v][a-z] 127 | [._]*.sw[a-p] 128 | [._]s[a-rt-v][a-z] 129 | [._]ss[a-gi-z] 130 | [._]sw[a-p] 131 | 132 | # Session 133 | Session.vim 134 | Sessionx.vim 135 | 136 | # Temporary 137 | .netrwhist 138 | # Auto-generated tag files 139 | tags 140 | # Persistent undo 141 | [._]*.un~ 142 | 143 | ### VisualStudioCode ### 144 | .vscode/* 145 | !.vscode/settings.json 146 | !.vscode/tasks.json 147 | !.vscode/launch.json 148 | !.vscode/extensions.json 149 | 150 | ### VisualStudioCode Patch ### 151 | # Ignore all local history of files 152 | .history 153 | 154 | ### Windows ### 155 | # Windows thumbnail cache files 156 | Thumbs.db 157 | Thumbs.db:encryptable 158 | ehthumbs.db 159 | ehthumbs_vista.db 160 | 161 | # Dump file 162 | *.stackdump 163 | 164 | # Folder config file 165 | [Dd]esktop.ini 166 | 167 | # Recycle Bin used on file shares 168 | $RECYCLE.BIN/ 169 | 170 | # Windows Installer files 171 | *.cab 172 | *.msi 173 | *.msix 174 | *.msm 175 | *.msp 176 | 177 | # Windows shortcuts 178 | *.lnk 179 | 180 | # End of https://www.gitignore.io/api/c,vim,linux,macos,elixir,windows,visualstudiocode 181 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2020 Software Mansion 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![boombox_transaprent](https://github.com/user-attachments/assets/1c5f25a2-cc27-4349-ae72-91315d43d6a1) 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/v/boombox.svg)](https://hex.pm/packages/boombox) 4 | [![API Docs](https://img.shields.io/badge/api-docs-yellow.svg?style=flat)](https://hexdocs.pm/boombox) 5 | [![CircleCI](https://circleci.com/gh/membraneframework/boombox.svg?style=svg)](https://circleci.com/gh/membraneframework/boombox) 6 | 7 | Boombox is a high-level tool for audio & video streaming tool based on the [Membrane Framework](https://membrane.stream). 8 | 9 | See [examples.livemd](examples.livemd) for examples. 10 | 11 | ## Usage 12 | 13 | The code below receives a stream via RTMP and sends it over HLS: 14 | 15 | ```elixir 16 | Boombox.run(input: "rtmp://localhost:5432", output: "index.m3u8") 17 | ``` 18 | 19 | you can use CLI interface too: 20 | 21 | ```sh 22 | boombox -i "rtmp://localhost:5432" -o "index.m3u8" 23 | ``` 24 | 25 | And the code below generates a video with bouncing Membrane logo and sends it over WebRTC: 26 | 27 | ```elixir 28 | Mix.install([:boombox, :req, :image]) 29 | 30 | overlay = 31 | Req.get!("https://avatars.githubusercontent.com/u/25247695?s=200&v=4").body 32 | |> Vix.Vips.Image.new_from_buffer() 33 | |> then(fn {:ok, img} -> img end) 34 | |> Image.trim!() 35 | |> Image.thumbnail!(100) 36 | 37 | bg = Image.new!(640, 480, color: :light_gray) 38 | max_x = Image.width(bg) - Image.width(overlay) 39 | max_y = Image.height(bg) - Image.height(overlay) 40 | 41 | Stream.iterate({_x = 300, _y = 0, _dx = 1, _dy = 2, _pts = 0}, fn {x, y, dx, dy, pts} -> 42 | dx = if (x + dx) in 0..max_x, do: dx, else: -dx 43 | dy = if (y + dy) in 0..max_y, do: dy, else: -dy 44 | pts = pts + div(Membrane.Time.seconds(1), _fps = 60) 45 | {x + dx, y + dy, dx, dy, pts} 46 | end) 47 | |> Stream.map(fn {x, y, _dx, _dy, pts} -> 48 | img = Image.compose!(bg, overlay, x: x, y: y) 49 | %Boombox.Packet{kind: :video, payload: img, pts: pts} 50 | end) 51 | |> Boombox.run( 52 | input: {:stream, video: :image, audio: false}, 53 | output: {:webrtc, "ws://localhost:8830"} 54 | ) 55 | ``` 56 | 57 | To receive WebRTC/HLS from boombox in a browser or send WebRTC from a browser to boombox 58 | you can use simple HTML examples in the `boombox_examples_data` folder, for example 59 | 60 | ```sh 61 | wget https://raw.githubusercontent.com/membraneframework/boombox/v0.1.0/boombox_examples_data/webrtc_to_browser.html 62 | open webrtc_to_browser.html 63 | ``` 64 | 65 | For more examples, see [examples.livemd](examples.livemd). 66 | 67 | ## Supported formats & protocols 68 | 69 | | format | input | output | 70 | |---|---|---| 71 | | MP4 | `"*.mp4"` | `"*.mp4"` | 72 | | WebRTC | `{:webrtc, signaling}` | `{:webrtc, signaling}` | 73 | | WHIP | `{:whip, "http://*", token: "token"}` | `{:whip, "http://*", token: "token"}` | 74 | | RTMP | `"rtmp://*"` | _not supported_ | 75 | | RTSP | `"rtsp://*"` | _not supported_ | 76 | | RTP | `{:rtp, opts}` | _not yet supported_ | 77 | | HLS | _not supported_ | `"*.m3u8"` | 78 | | `Enumerable.t()` | `{:stream, opts}` | `{:stream, opts}` | 79 | 80 | For the full list of input and output options, see [`Boombox.run/2`](https://hexdocs.pm/boombox/Boombox.html#run/2) 81 | 82 | ## Installation 83 | 84 | To use Boombox as an Elixir library, add 85 | 86 | ```elixir 87 | {:boombox, "~> 0.2.1"} 88 | ``` 89 | 90 | to your dependencies or `Mix.install`. 91 | 92 | to use via CLI, run the following: 93 | 94 | ```sh 95 | wget https://raw.githubusercontent.com/membraneframework/boombox/v0.1.0/bin/boombox 96 | chmod u+x boombox 97 | ./boombox 98 | ``` 99 | 100 | Make sure you have [Elixir](https://elixir-lang.org/) installed. The first call to `boombox` will install it in a default directory in the system. The directory can be set with `MIX_INSTALL_DIR` env variable if preferred. 101 | 102 | ## CLI 103 | 104 | The CLI API is a direct mapping of the Elixir API: 105 | * `:input` and `:output` options of `Boombox.run/2` are mapped to `-i` and `-o` CLI arguments respectively. 106 | * Option names, like `:some_option`, are mapped to CLI arguments by removing the colon, adding a leading double hyphen and replacing all underscores with hyphens, like `--some-option`. 107 | * Option values mappings depend on the option's type: 108 | - String values, like `"some_value"`, are mapped to CLI arguments by stripping the quotes, like `some_value`. 109 | - Atom values, like `:some_value`, are mapped to CLI arguments by stripping the leading colon, like `some_value`. 110 | - Integer values are identical in elixir and CLI. 111 | - Binary values, like `<<161, 63>>`, are represented in CLI as their representation in base 16, like `a13f`. 112 | 113 | For example: 114 | 115 | ```elixir 116 | Boombox.run(input: "file.mp4", output: {:whip, "http://localhost:3721", token: "token"}) 117 | Boombox.run( 118 | input: 119 | {:rtp, 120 | port: 50001, 121 | audio_encoding: :AAC, 122 | audio_specific_config: <<161, 63>>, 123 | aac_bitrate_mode: :hbr}, 124 | output: "index.m3u8" 125 | ) 126 | ``` 127 | 128 | are equivalent to: 129 | 130 | ```sh 131 | ./boombox -i file.mp4 -o --whip http://localhost:3721 --token token 132 | ./boombox -i --rtp --port 50001 --audio-encoding AAC --audio-specific-config a13f --aac-bitrate-mode hbr -o index.m3u8 133 | ``` 134 | 135 | It's also possible to pass an `.exs` script: 136 | 137 | ```sh 138 | ./boombox -s script.exs 139 | ``` 140 | 141 | In the script you can call `Boombox.run(...)` and execute other Elixir code. 142 | 143 | The first run of the CLI may take longer than usual, as the necessary artifacts are installed in the system. 144 | 145 | ## Authors 146 | 147 | Boombox is created by Software Mansion. 148 | 149 | Since 2012 [Software Mansion](https://swmansion.com/?utm_source=git&utm_medium=readme&utm_campaign=boombox) is a software agency with experience in building web and mobile apps as well as complex multimedia solutions. We are Core React Native Contributors and experts in live streaming and broadcasting technologies. We can help you build your next dream product – [Hire us](https://swmansion.com/contact/projects). 150 | 151 | Copyright 2024, [Software Mansion](https://swmansion.com/?utm_source=git&utm_medium=readme&utm_campaign=boombox) 152 | 153 | [![Software Mansion](https://logo.swmansion.com/logo?color=white&variant=desktop&width=200&tag=membrane-github)](https://swmansion.com/?utm_source=git&utm_medium=readme&utm_campaign=boombox) 154 | 155 | Licensed under the [Apache License, Version 2.0](LICENSE) 156 | -------------------------------------------------------------------------------- /bin/boombox: -------------------------------------------------------------------------------- 1 | #/bin/sh 2 | elixir -e 'Logger.configure(level: :info);Mix.install([:boombox]);Boombox.run_cli()' $@ -------------------------------------------------------------------------------- /bin/boombox_local: -------------------------------------------------------------------------------- 1 | #/bin/sh 2 | mix run -e 'Logger.configure(level: :info);Boombox.run_cli()' -- $@ -------------------------------------------------------------------------------- /boombox_examples_data/hls.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 14 |

Boombox HLS Example

15 | Source:
16 |   17 |

18 | 19 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /boombox_examples_data/talk_to_llm.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Boombox talk to LLM demo 9 | 10 | 11 | 13 |
14 |

Boombox talk to LLM demo

15 |
Connecting
16 | 17 |
18 | 116 | 117 | 118 | -------------------------------------------------------------------------------- /boombox_examples_data/webrtc_from_browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Boombox stream WebRTC from browser example 9 | 10 | 11 | 13 |
14 |

Boombox stream WebRTC from browser example

15 |
16 | Boombox URL: 17 |
18 |
19 |
20 | 21 |
22 | 23 | 95 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /boombox_examples_data/webrtc_to_browser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Boombox stream WebRTC to browser example 9 | 10 | 11 | 13 |
14 |

Boombox stream WebRTC to browser example

15 |
16 | Boombox URL: 17 |
18 |
19 |
20 | 21 |
22 | 81 | 82 | 83 | -------------------------------------------------------------------------------- /boombox_examples_data/whip.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Membrane WebRTC WHIP/WHEP Example 9 | 10 | 11 | 13 |

Boombox WHIP Example

14 |
15 | Boombox URL: 16 | Token: 17 | 18 |
19 |
20 |
21 | 22 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /build_binary.sh: -------------------------------------------------------------------------------- 1 | MIX_ENV=prod BOOMBOX_BURRITO=true mix release --overwrite && \ 2 | mv burrito_out/boombox_current boombox && \ 3 | rm -r burrito_out && \ 4 | # Burrito extracts the compressed artifacts into a common 5 | # location in the system on a first run, and then reuses 6 | # those artifacts and checks the version in mix.exs to 7 | # know whether it car reuse them. So we need to uninstall 8 | # the artifacts to force burrito to extract them again 9 | # even if the version in mix.exs didn't change 10 | yes | ./boombox maintenance uninstall 11 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | if config_env() == :prod do 4 | config :logger, level: :warning 5 | end 6 | -------------------------------------------------------------------------------- /lib/boombox.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox do 2 | @moduledoc """ 3 | Boombox is a tool for audio and video streaming. 4 | 5 | See `run/1` for details and [examples.livemd](examples.livemd) for examples. 6 | """ 7 | 8 | require Logger 9 | require Membrane.Time 10 | require Membrane.Transcoder.{Audio, Video} 11 | 12 | alias Membrane.RTP 13 | 14 | @type transcoding_policy_opt :: {:transcoding_policy, :always | :if_needed | :never} 15 | 16 | @type webrtc_signaling :: Membrane.WebRTC.Signaling.t() | String.t() 17 | @type in_stream_opts :: [ 18 | {:audio, :binary | boolean()} 19 | | {:video, :image | boolean()} 20 | ] 21 | @type out_stream_opts :: [ 22 | {:audio, :binary | boolean()} 23 | | {:video, :image | boolean()} 24 | | {:audio_format, Membrane.RawAudio.SampleFormat.t()} 25 | | {:audio_rate, Membrane.RawAudio.sample_rate_t()} 26 | | {:audio_channels, Membrane.RawAudio.channels_t()} 27 | | {:video_width, non_neg_integer()} 28 | | {:video_height, non_neg_integer()} 29 | ] 30 | 31 | @typedoc """ 32 | When configuring a track for a media type (video or audio), the following options are used: 33 | * _encoding - MUST be provided to configure given media type. Some options are encoding-specific. Currently supported encodings are: AAC, Opus, H264, H265. 34 | * _payload_type, _clock rate - MAY be provided. If not, an unofficial default will be used. 35 | The following encoding-specific parameters are available for both RTP input and output: 36 | * aac_bitrate_mode - MUST be provided for AAC encoding. Defines which mode should be assumed/set when depayloading/payloading. 37 | """ 38 | @type common_rtp_opt :: 39 | {:video_encoding, RTP.encoding_name()} 40 | | {:video_payload_type, RTP.payload_type()} 41 | | {:video_clock_rate, RTP.clock_rate()} 42 | | {:audio_encoding, RTP.encoding_name()} 43 | | {:audio_payload_type, RTP.payload_type()} 44 | | {:audio_clock_rate, RTP.clock_rate()} 45 | | {:aac_bitrate_mode, RTP.AAC.Utils.mode()} 46 | 47 | @typedoc """ 48 | In order to configure a RTP input a receiving port MUST be provided and the media that will be received 49 | MUST be configured. Media configuration is explained further in `t:common_rtp_opt/0`. 50 | 51 | The following encoding-specific parameters are available for RTP input: 52 | * audio_specific_config - MUST be provided for AAC encoding. Contains crucial information about the stream and has to be obtained from a side channel. 53 | * vps (H265 only), pps, sps - MAY be provided for H264 or H265 encodings. Parameter sets, could be obtained from a side channel. They contain information about the encoded stream. 54 | """ 55 | @type in_rtp_opts :: [ 56 | common_rtp_opt() 57 | | {:port, :inet.port_number()} 58 | | {:audio_specific_config, binary()} 59 | | {:vps, binary()} 60 | | {:pps, binary()} 61 | | {:sps, binary()} 62 | ] 63 | 64 | @typedoc """ 65 | In order to configure a RTP output the target port and address MUST be provided (can be provided in `:target` option as a `
:` string) 66 | and the media that will be sent MUST be configured. Media configuration is explained further in `t:common_rtp_opt/0`. 67 | """ 68 | @type out_rtp_opts :: [ 69 | common_rtp_opt() 70 | | {:address, :inet.ip_address() | String.t()} 71 | | {:port, :inet.port_number()} 72 | | {:target, String.t()} 73 | | transcoding_policy_opt() 74 | ] 75 | 76 | @type input :: 77 | (path_or_uri :: String.t()) 78 | | {:mp4 | :aac | :wav | :mp3 | :ivf | :ogg | :h264 | :h265, location :: String.t()} 79 | | {:mp4 | :aac | :wav | :mp3 | :ivf | :ogg, location :: String.t(), 80 | transport: :file | :http} 81 | | {:h264, location :: String.t(), 82 | transport: :file | :http, framerate: Membrane.H264.framerate()} 83 | | {:h265, location :: String.t(), 84 | transport: :file | :http, framerate: Membrane.H265.framerate_t()} 85 | | {:webrtc, webrtc_signaling()} 86 | | {:whip, uri :: String.t(), token: String.t()} 87 | | {:rtmp, (uri :: String.t()) | (client_handler :: pid)} 88 | | {:rtsp, url :: String.t()} 89 | | {:rtp, in_rtp_opts()} 90 | | {:stream, in_stream_opts()} 91 | 92 | @type output :: 93 | (path_or_uri :: String.t()) 94 | | {path_or_uri :: String.t(), [transcoding_policy_opt()]} 95 | | {:mp4 | :aac | :wav | :mp3 | :ivf | :ogg | :h264 | :h265, location :: String.t()} 96 | | {:mp4 | :aac | :wav | :mp3 | :ivf | :ogg | :h264 | :h265, location :: String.t(), 97 | [transcoding_policy_opt()]} 98 | | {:webrtc, webrtc_signaling()} 99 | | {:webrtc, webrtc_signaling(), [transcoding_policy_opt()]} 100 | | {:whip, uri :: String.t(), 101 | [{:token, String.t()} | {bandit_option :: atom(), term()} | transcoding_policy_opt()]} 102 | | {:hls, location :: String.t()} 103 | | {:hls, location :: String.t(), [transcoding_policy_opt()]} 104 | | {:rtp, out_rtp_opts()} 105 | | {:stream, out_stream_opts()} 106 | 107 | @typep procs :: %{pipeline: pid(), supervisor: pid()} 108 | @typep opts_map :: %{ 109 | input: input(), 110 | output: output(), 111 | parent: pid() 112 | } 113 | 114 | @doc """ 115 | Runs boombox with given input and output. 116 | 117 | ## Example 118 | 119 | ``` 120 | Boombox.run(input: "rtmp://localhost:5432", output: "index.m3u8") 121 | ``` 122 | 123 | See `t:input/0` and `t:output/0` for available outputs and [examples.livemd](examples.livemd) 124 | for examples. 125 | 126 | If the input is `{:stream, opts}`, a `Stream` or other `Enumerable` is expected 127 | as the first argument. 128 | ``` 129 | Boombox.run( 130 | input: "path/to/file.mp4", 131 | output: {:webrtc, "ws://0.0.0.0:1234"} 132 | ) 133 | ``` 134 | """ 135 | @spec run(Enumerable.t() | nil, 136 | input: input(), 137 | output: output() 138 | ) :: :ok | Enumerable.t() 139 | def run(stream \\ nil, opts) do 140 | opts = validate_opts!(stream, opts) 141 | 142 | case opts do 143 | %{input: {:stream, _stream_opts}} -> 144 | procs = start_pipeline(opts) 145 | source = await_source_ready() 146 | consume_stream(stream, source, procs) 147 | 148 | %{output: {:stream, _stream_opts}} -> 149 | procs = start_pipeline(opts) 150 | sink = await_sink_ready() 151 | produce_stream(sink, procs) 152 | 153 | opts -> 154 | opts 155 | |> start_pipeline() 156 | |> await_pipeline() 157 | end 158 | end 159 | 160 | @doc """ 161 | Asynchronous version of run/2 162 | Doesn't block the calling process until the termination of the pipeline. 163 | It returns a `Task` that can be awaited later. 164 | If the output is a stream the behaviour is identical to run/2 165 | """ 166 | @spec async(Enumerable.t() | nil, 167 | input: input(), 168 | output: output() 169 | ) :: Task.t() | Enumerable.t() 170 | def async(stream \\ nil, opts) do 171 | opts = validate_opts!(stream, opts) 172 | 173 | case opts do 174 | %{input: {:stream, _stream_opts}} -> 175 | procs = start_pipeline(opts) 176 | source = await_source_ready() 177 | 178 | Task.async(fn -> 179 | Process.monitor(procs.supervisor) 180 | consume_stream(stream, source, procs) 181 | end) 182 | 183 | %{output: {:stream, _stream_opts}} -> 184 | procs = start_pipeline(opts) 185 | sink = await_sink_ready() 186 | produce_stream(sink, procs) 187 | 188 | # In case of rtmp, rtmps, rtp, rtsp, we need to wait for the tcp/udp server to be ready 189 | # before returning from async/2. 190 | %{input: {protocol, _opts}} when protocol in [:rtmp, :rtmps, :rtp, :rtsp] -> 191 | procs = start_pipeline(opts) 192 | 193 | task = 194 | Task.async(fn -> 195 | Process.monitor(procs.supervisor) 196 | await_pipeline(procs) 197 | end) 198 | 199 | await_external_resource_ready() 200 | task 201 | 202 | opts -> 203 | procs = start_pipeline(opts) 204 | 205 | Task.async(fn -> 206 | Process.monitor(procs.supervisor) 207 | await_pipeline(procs) 208 | end) 209 | end 210 | end 211 | 212 | @endpoint_opts [:input, :output] 213 | defp validate_opts!(stream, opts) do 214 | opts = opts |> Keyword.validate!(@endpoint_opts) |> Map.new() 215 | 216 | cond do 217 | Map.keys(opts) -- @endpoint_opts != [] -> 218 | raise ArgumentError, 219 | "Both input and output are required. #{@endpoint_opts -- Map.keys(opts)} were not provided" 220 | 221 | stream?(opts[:input]) && !Enumerable.impl_for(stream) -> 222 | raise ArgumentError, 223 | "Expected Enumerable.t() to be passed as the first argument, got #{inspect(stream)}" 224 | 225 | stream?(opts[:input]) && stream?(opts[:output]) -> 226 | raise ArgumentError, 227 | ":stream on both input and output is not supported" 228 | 229 | true -> 230 | opts 231 | end 232 | end 233 | 234 | defp stream?({:stream, _opts}), do: true 235 | defp stream?(_io), do: false 236 | 237 | @doc """ 238 | Runs boombox with CLI arguments. 239 | 240 | ## Example 241 | ``` 242 | # boombox.exs 243 | Mix.install([:boombox]) 244 | Boombox.run_cli() 245 | ``` 246 | 247 | ```sh 248 | elixir boombox.exs -i "rtmp://localhost:5432" -o "index.m3u8" 249 | ``` 250 | """ 251 | @spec run_cli([String.t()]) :: :ok 252 | def run_cli(argv \\ System.argv()) do 253 | case Boombox.Utils.CLI.parse_argv(argv) do 254 | {:args, args} -> run(args) 255 | {:script, script} -> Code.eval_file(script) 256 | end 257 | end 258 | 259 | @spec consume_stream(Enumerable.t(), pid(), procs()) :: term() 260 | defp consume_stream(stream, source, procs) do 261 | Enum.reduce_while( 262 | stream, 263 | %{demand: 0}, 264 | fn 265 | %Boombox.Packet{} = packet, %{demand: 0} = state -> 266 | receive do 267 | {:boombox_demand, demand} -> 268 | send(source, packet) 269 | {:cont, %{state | demand: demand - 1}} 270 | 271 | {:DOWN, _monitor, :process, supervisor, _reason} 272 | when supervisor == procs.supervisor -> 273 | {:halt, :terminated} 274 | end 275 | 276 | %Boombox.Packet{} = packet, %{demand: demand} = state -> 277 | send(source, packet) 278 | {:cont, %{state | demand: demand - 1}} 279 | 280 | value, _state -> 281 | raise ArgumentError, "Expected Boombox.Packet.t(), got: #{inspect(value)}" 282 | end 283 | ) 284 | |> case do 285 | :terminated -> 286 | :ok 287 | 288 | _state -> 289 | send(source, :boombox_eos) 290 | await_pipeline(procs) 291 | end 292 | end 293 | 294 | @spec produce_stream(pid(), procs()) :: Enumerable.t() 295 | defp produce_stream(sink, procs) do 296 | Stream.resource( 297 | fn -> 298 | %{sink: sink, procs: procs} 299 | end, 300 | fn %{sink: sink, procs: procs} = state -> 301 | send(sink, :boombox_demand) 302 | 303 | receive do 304 | %Boombox.Packet{} = packet -> 305 | verify_packet!(packet) 306 | {[packet], state} 307 | 308 | {:DOWN, _monitor, :process, supervisor, _reason} 309 | when supervisor == procs.supervisor -> 310 | {:halt, :eos} 311 | end 312 | end, 313 | fn 314 | %{procs: procs} -> terminate_pipeline(procs) 315 | :eos -> :ok 316 | end 317 | ) 318 | end 319 | 320 | @spec start_pipeline(opts_map()) :: procs() 321 | defp start_pipeline(opts) do 322 | opts = 323 | opts 324 | |> Map.update!(:input, &resolve_stream_endpoint(&1, self())) 325 | |> Map.update!(:output, &resolve_stream_endpoint(&1, self())) 326 | |> Map.put(:parent, self()) 327 | 328 | {:ok, supervisor, pipeline} = 329 | Membrane.Pipeline.start_link(Boombox.Pipeline, opts) 330 | 331 | Process.monitor(supervisor) 332 | %{supervisor: supervisor, pipeline: pipeline} 333 | end 334 | 335 | @spec terminate_pipeline(procs) :: :ok 336 | defp terminate_pipeline(procs) do 337 | Membrane.Pipeline.terminate(procs.pipeline) 338 | await_pipeline(procs) 339 | end 340 | 341 | @spec await_pipeline(procs) :: :ok 342 | defp await_pipeline(%{supervisor: supervisor}) do 343 | receive do 344 | {:DOWN, _monitor, :process, ^supervisor, _reason} -> :ok 345 | end 346 | end 347 | 348 | @spec await_source_ready() :: pid() 349 | defp await_source_ready() do 350 | receive do 351 | {:boombox_ex_stream_source, source} -> source 352 | end 353 | end 354 | 355 | @spec await_sink_ready() :: pid() 356 | defp await_sink_ready() do 357 | receive do 358 | {:boombox_ex_stream_sink, sink} -> sink 359 | end 360 | end 361 | 362 | # Waits for the external resource to be ready. 363 | # This is used to wait for the tcp/udp server to be ready before returning from async/2. 364 | # It is used for rtmp, rtmps, rtp, rtsp. 365 | @spec await_external_resource_ready() :: :ok 366 | defp await_external_resource_ready() do 367 | receive do 368 | :external_resource_ready -> 369 | :ok 370 | end 371 | end 372 | 373 | @spec verify_packet!(term()) :: :ok 374 | defp verify_packet!(packet) do 375 | %Boombox.Packet{kind: kind, pts: pts, format: format} = packet 376 | 377 | unless kind in [:audio, :video] do 378 | raise "Boombox.Packet field :kind must be set to :audio or :video, got #{inspect(kind)}" 379 | end 380 | 381 | unless Membrane.Time.is_time(pts) do 382 | raise "Boombox.Packet field :pts must be of type `Membrane.Time.t`, got #{inspect(pts)}" 383 | end 384 | 385 | unless is_map(format) do 386 | raise "Boombox.Packet field :format must be a map, got #{inspect(format)}" 387 | end 388 | 389 | :ok 390 | end 391 | 392 | defp resolve_stream_endpoint({:stream, stream_options}, parent), 393 | do: {:stream, parent, stream_options} 394 | 395 | defp resolve_stream_endpoint(endpoint, _parent), do: endpoint 396 | end 397 | -------------------------------------------------------------------------------- /lib/boombox/application.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.Application do 2 | @moduledoc """ 3 | Boombox application. If released with release name set to `"server"`, Boombox.Server will be 4 | started under the supervision tree. If `BOOMBOX_NODE_TO_PING` environment variable is set, 5 | then a node with the provided name will be pinged. 6 | """ 7 | use Application 8 | 9 | @impl true 10 | def start(_type, _args) do 11 | case System.fetch_env("RELEASE_NAME") do 12 | {:ok, "server"} -> 13 | start_server() 14 | 15 | _not_server -> 16 | Supervisor.start_link([], strategy: :one_for_one) 17 | end 18 | end 19 | 20 | defp start_server() do 21 | case System.fetch_env("BOOMBOX_NODE_TO_PING") do 22 | :error -> 23 | :ok 24 | 25 | {:ok, node_to_ping} -> 26 | case Node.ping(String.to_atom(node_to_ping)) do 27 | :pong -> :ok 28 | :pang -> raise "Connection with node #{node_to_ping} failed." 29 | end 30 | end 31 | 32 | Supervisor.start_link([Boombox.Server], strategy: :one_for_one) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/boombox/bin.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.Bin do 2 | @moduledoc """ 3 | `Boombox.Bin` is a Membrane Bin for audio and video streaming. 4 | It can be used as a Sink or Source in your Membrane Pipeline. 5 | 6 | If you use it as a Membrane Source and link `:output` pad, you 7 | have to specify `:input` option, e.g. 8 | ```elixir 9 | child(:boombox, %Boombox.Bin{ 10 | input: "path/to/input/file.mp4" 11 | }) 12 | |> via_out(:output, options: [kind: :audio]) 13 | |> child(:my_audio_sink, My.Audio.Sink) 14 | ``` 15 | 16 | If you use it as a Membrane Sink and link `:input` pad, you have 17 | to specify `:output` option, e.g. 18 | ```elixir 19 | child(:my_video_source, My.Video.Source) 20 | |> via_in(:input, options: [kind: :video]) 21 | |> child(:boombox, %Boombox.Bin{ 22 | output: "path/to/output/file.mp4" 23 | }) 24 | ``` 25 | 26 | `Boombox.Bin` cannot have `:input` and `:output` pads linked at 27 | the same time. 28 | 29 | `Boombox.Bin` cannot have `:input` and `:output` options set 30 | at the same time. 31 | 32 | If you use Boombox.Bin as a source, you can either: 33 | * link output pads in the same spec where you spawn it 34 | * or wait until Boombox.Bin returns a notification `{:new_tracks, [:audio | :video]}` 35 | and then link the pads according to the notification. 36 | """ 37 | 38 | use Membrane.Bin 39 | 40 | require Membrane.Pad, as: Pad 41 | require Membrane.Transcoder.{Audio, Video} 42 | 43 | alias Membrane.{AAC, H264, Opus, RawAudio, RawVideo, Transcoder, VP8, VP9} 44 | 45 | @allowed_codecs %{ 46 | audio: [AAC, Opus, RawAudio], 47 | video: [H264, VP8, VP9, RawVideo] 48 | } 49 | 50 | @typedoc """ 51 | Value passed via `:input` option. 52 | 53 | Specifies the input endpoint of `#{inspect(__MODULE__)}`. 54 | 55 | Similar to `t:Boombox.input/0`, but without `:stream` option. 56 | """ 57 | @type input() :: 58 | (path_or_uri :: String.t()) 59 | | {:mp4 | :aac | :wav | :mp3 | :ivf | :ogg | :h264 | :h265, location :: String.t()} 60 | | {:mp4 | :aac | :wav | :mp3 | :ivf | :ogg, location :: String.t(), 61 | transport: :file | :http} 62 | | {:h264, location :: String.t(), 63 | transport: :file | :http, framerate: Membrane.H264.framerate()} 64 | | {:h265, location :: String.t(), 65 | transport: :file | :http, framerate: Membrane.H265.framerate_t()} 66 | | {:webrtc, Boombox.webrtc_signaling()} 67 | | {:whip, uri :: String.t(), token: String.t()} 68 | | {:rtmp, (uri :: String.t()) | (client_handler :: pid)} 69 | | {:rtsp, url :: String.t()} 70 | | {:rtp, Boombox.in_rtp_opts()} 71 | 72 | @typedoc """ 73 | Value passed via `:output` option. 74 | 75 | Specifies the output endpoint of `#{inspect(__MODULE__)}`. 76 | 77 | Similar to `t:Boombox.output/0`, but without `:stream` option. 78 | """ 79 | @type output :: 80 | (path_or_uri :: String.t()) 81 | | {path_or_uri :: String.t(), [Boombox.transcoding_policy_opt()]} 82 | | {:mp4 | :aac | :wav | :mp3 | :ivf | :ogg | :h264 | :h265, location :: String.t()} 83 | | {:mp4 | :aac | :wav | :mp3 | :ivf | :ogg | :h264 | :h265, location :: String.t(), 84 | [Boombox.transcoding_policy_opt()]} 85 | | {:webrtc, Boombox.webrtc_signaling()} 86 | | {:webrtc, Boombox.webrtc_signaling(), [Boombox.transcoding_policy_opt()]} 87 | | {:whip, uri :: String.t(), 88 | [ 89 | {:token, String.t()} 90 | | {bandit_option :: atom(), term()} 91 | | Boombox.transcoding_policy_opt() 92 | ]} 93 | | {:hls, location :: String.t()} 94 | | {:hls, location :: String.t(), [Boombox.transcoding_policy_opt()]} 95 | | {:rtp, Boombox.out_rtp_opts()} 96 | 97 | @typedoc """ 98 | Type of notification sent to the parent of Boombox.Bin when new tracks arrive. 99 | 100 | It is sent only when Boombox.Bin is used as a source (the `:input` option is set). 101 | 102 | This notification is sent by Boombox.Bin only if its pads weren't linked 103 | before `handle_playing/2` callback. 104 | """ 105 | @type new_tracks :: {:new_tracks, [:video | :audio]} 106 | 107 | def_input_pad :input, 108 | accepted_format: 109 | format 110 | when Transcoder.Audio.is_audio_format(format) or Transcoder.Video.is_video_format(format), 111 | availability: :on_request, 112 | max_instances: 2, 113 | options: [ 114 | kind: [ 115 | spec: :video | :audio, 116 | description: """ 117 | Specifies, if the input pad is for audio or video. 118 | 119 | There might be up to one pad of each kind at the time. 120 | """ 121 | ] 122 | ] 123 | 124 | def_output_pad :output, 125 | accepted_format: 126 | format 127 | when Transcoder.Audio.is_audio_format(format) or Transcoder.Video.is_video_format(format), 128 | availability: :on_request, 129 | max_instances: 2, 130 | options: [ 131 | kind: [ 132 | spec: :video | :audio, 133 | description: """ 134 | Specifies, if the output pad is for audio or video. 135 | 136 | There might be up to one pad of each kind at the time. 137 | """ 138 | ], 139 | codec: [ 140 | spec: 141 | H264 142 | | VP8 143 | | VP9 144 | | AAC 145 | | Opus 146 | | RawVideo 147 | | RawAudio 148 | | [H264 | VP8 | VP9 | AAC | Opus | RawVideo | RawAudio] 149 | | nil, 150 | default: nil, 151 | description: """ 152 | Specifies the codec of the stream flowing through the pad. 153 | 154 | Can be either a single codec or a list of codecs. 155 | 156 | If a list is provided 157 | * and the stream matches one of the codecs, the matching codec will be used, 158 | * and the stream doesn't match any of the codecs, it will be transcoded to 159 | the first codec in the list. 160 | 161 | If the codec is not specified, it will be resolved to: 162 | * `Membrane.H264` for video, 163 | * `Membrane.AAC` for audio, if the input is not WebRTC, 164 | * `Membrane.Opus` for audio, if the input is WebRTC. 165 | """ 166 | ], 167 | transcoding_policy: [ 168 | spec: :always | :if_needed | :never, 169 | default: :if_needed, 170 | description: """ 171 | Specifies the transcoding policy for the stream flowing through the pad. 172 | 173 | Can be either `:always`, `:if_needed`, or `:never`. 174 | 175 | If set to `:always`, the media stream will be decoded and/or encoded, even if the 176 | format of the stream arriving at the #{inspect(__MODULE__)} endpoint matches the 177 | output pad codec. This option is useful when you want to make sure keyframe request 178 | events going upstream the output pad are handled in Boombox when the Boombox input 179 | itself cannot handle them (i.e. when the input is not WebRTC). 180 | 181 | If set to `:if_needed`, the media stream will be transcoded only if the format of 182 | the stream arriving at the #{inspect(__MODULE__)} endpoint doesn't match the output 183 | pad codec. 184 | This is the default behavior. 185 | 186 | If set to `:never`, the input media stream won't be decoded or encoded. 187 | Changing alignment, encapsulation, or stream structure is still possible. This option 188 | is helpful when you want to ensure that #{inspect(__MODULE__)} will not use too many 189 | resources, e.g., CPU or memory. 190 | 191 | If the stream arriving at the #{inspect(__MODULE__)} endpoint doesn't match the output 192 | pad codec, an error will be raised. 193 | """ 194 | ] 195 | ] 196 | 197 | def_options input: [ 198 | spec: input() | nil, 199 | default: nil 200 | ], 201 | output: [ 202 | spec: output() | nil, 203 | default: nil 204 | ] 205 | 206 | @impl true 207 | def handle_init(_ctx, opts) do 208 | :ok = validate_opts!(opts) 209 | 210 | spec = 211 | child(:boombox, %Boombox.InternalBin{ 212 | input: opts.input || :membrane_pad, 213 | output: opts.output || :membrane_pad 214 | }) 215 | 216 | {[spec: spec], Map.from_struct(opts)} 217 | end 218 | 219 | @impl true 220 | def handle_pad_added(Pad.ref(pad_name, _id) = pad_ref, ctx, state) do 221 | :ok = validate_pads!(ctx.pads) 222 | 223 | pad_options = Map.to_list(ctx.pad_options) 224 | 225 | spec = 226 | case pad_name do 227 | :input -> 228 | bin_input(pad_ref) 229 | |> via_in(:input, options: pad_options) 230 | |> get_child(:boombox) 231 | 232 | :output -> 233 | get_child(:boombox) 234 | |> via_out(:output, options: pad_options) 235 | |> bin_output(pad_ref) 236 | end 237 | 238 | {[spec: spec], state} 239 | end 240 | 241 | @impl true 242 | def handle_child_notification(:processing_finished, :boombox, _ctx, state) do 243 | {[notify_parent: :processing_finished], state} 244 | end 245 | 246 | @impl true 247 | def handle_child_notification({:new_tracks, tracks}, :boombox, _ctx, state) do 248 | {[notify_parent: {:new_tracks, tracks}], state} 249 | end 250 | 251 | defp validate_opts!(opts) do 252 | nil_opts_number = 253 | [opts.input, opts.output] 254 | |> Enum.count(&(&1 == nil)) 255 | 256 | case nil_opts_number do 257 | 0 -> 258 | raise """ 259 | #{inspect(__MODULE__)} cannot accept input and output options at the same time, but both were provided.\ 260 | Input: #{inspect(opts.input)}, output: #{inspect(opts.output)}. 261 | """ 262 | 263 | 1 -> 264 | :ok 265 | 266 | 2 -> 267 | raise "#{inspect(__MODULE__)} requires either input or output option to be set, but none were provided." 268 | end 269 | 270 | [:input, :output] 271 | |> Enum.each(fn direction -> 272 | option = opts |> Map.get(direction) 273 | 274 | if is_tuple(option) and elem(option, 0) == :stream do 275 | raise """ 276 | #{inspect(direction)} option is set to #{inspect(option)}, but #{inspect(__MODULE__)} \ 277 | doesn't support Elixir Stream as an endpoint. 278 | """ 279 | end 280 | end) 281 | end 282 | 283 | defp validate_pads!(pads) do 284 | pads 285 | |> Enum.each(fn {pad_ref, %{options: options}} -> 286 | if Pad.name_by_ref(pad_ref) == :output do 287 | :ok = validate_codec_pad_option!(pad_ref, options.codec, options.kind) 288 | end 289 | end) 290 | 291 | pads 292 | |> Enum.group_by(fn {Pad.ref(name, _id), %{options: %{kind: kind}}} -> 293 | {name, kind} 294 | end) 295 | |> Enum.find(fn {_key, pads} -> length(pads) > 1 end) 296 | |> case do 297 | nil -> 298 | :ok 299 | 300 | {_key, pads} -> 301 | raise """ 302 | #{inspect(__MODULE__)} supports only one input and one output pad of each kind. \ 303 | Found multiple pads of the same direction and kind: #{Map.keys(pads) |> inspect()} 304 | """ 305 | end 306 | end 307 | 308 | defp validate_codec_pad_option!(pad_ref, codec, kind) do 309 | codecs = Bunch.listify(codec) 310 | 311 | if codecs == [nil] or codecs -- @allowed_codecs[kind] == [] do 312 | :ok 313 | else 314 | raise """ 315 | Pad #{inspect(pad_ref)} is of kind #{inspect(kind)} and it has :codec option set \ 316 | to #{inspect(codec)}. \ 317 | Supported codecs for #{inspect(kind)} kind are: #{inspect(@allowed_codecs[kind])}. 318 | """ 319 | end 320 | end 321 | end 322 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/elixir_stream.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.ElixirStream do 2 | @moduledoc false 3 | 4 | import Membrane.ChildrenSpec 5 | require Membrane.Pad, as: Pad 6 | 7 | alias __MODULE__.{Sink, Source} 8 | alias Boombox.InternalBin.Ready 9 | alias Membrane.FFmpeg.SWScale 10 | 11 | @options_audio_keys [:audio_format, :audio_rate, :audio_channels] 12 | 13 | @spec create_input(producer :: pid, options :: Boombox.in_stream_opts()) :: Ready.t() 14 | def create_input(producer, options) do 15 | options = parse_options(options, :input) 16 | 17 | builders = 18 | [:audio, :video] 19 | |> Enum.filter(&(options[&1] != false)) 20 | |> Map.new(fn 21 | :video -> 22 | {:video, 23 | get_child(:elixir_stream_source) 24 | |> via_out(Pad.ref(:output, :video)) 25 | |> child(%SWScale.Converter{format: :I420}) 26 | |> child(%Membrane.H264.FFmpeg.Encoder{profile: :baseline, preset: :ultrafast})} 27 | 28 | :audio -> 29 | {:audio, 30 | get_child(:elixir_stream_source) 31 | |> via_out(Pad.ref(:output, :audio))} 32 | end) 33 | 34 | spec_builder = child(:elixir_stream_source, %Source{producer: producer}) 35 | 36 | %Ready{track_builders: builders, spec_builder: spec_builder} 37 | end 38 | 39 | @spec link_output( 40 | consumer :: pid, 41 | options :: Boombox.out_stream_opts(), 42 | Boombox.InternalBin.track_builders(), 43 | Membrane.ChildrenSpec.t() 44 | ) :: Ready.t() 45 | def link_output(consumer, options, track_builders, spec_builder) do 46 | options = parse_options(options, :output) 47 | 48 | {track_builders, to_ignore} = 49 | Map.split_with(track_builders, fn {kind, _builder} -> options[kind] != false end) 50 | 51 | spec = 52 | [ 53 | spec_builder, 54 | child(:elixir_stream_sink, %Sink{consumer: consumer}), 55 | Enum.map(track_builders, fn 56 | {:audio, builder} -> 57 | builder 58 | |> child(:mp4_audio_transcoder, %Membrane.Transcoder{ 59 | output_stream_format: Membrane.RawAudio 60 | }) 61 | |> maybe_plug_resampler(options) 62 | |> via_in(Pad.ref(:input, :audio)) 63 | |> get_child(:elixir_stream_sink) 64 | 65 | {:video, builder} -> 66 | builder 67 | |> child(:elixir_stream_video_transcoder, %Membrane.Transcoder{ 68 | output_stream_format: Membrane.RawVideo 69 | }) 70 | |> child(:elixir_stream_rgb_converter, %SWScale.Converter{ 71 | format: :RGB, 72 | output_width: options[:video_width], 73 | output_height: options[:video_height] 74 | }) 75 | |> via_in(Pad.ref(:input, :video)) 76 | |> get_child(:elixir_stream_sink) 77 | end), 78 | Enum.map(to_ignore, fn {_track, builder} -> builder |> child(Membrane.Debug.Sink) end) 79 | ] 80 | 81 | %Ready{actions: [spec: spec], eos_info: Map.keys(track_builders)} 82 | end 83 | 84 | @spec parse_options(Boombox.in_stream_opts(), :input) :: map() 85 | @spec parse_options(Boombox.out_stream_opts(), :output) :: map() 86 | defp parse_options(options, direction) do 87 | audio = Keyword.get(options, :audio) 88 | 89 | audio_keys = 90 | if direction == :output and audio != false and 91 | Enum.any?(@options_audio_keys, &Keyword.has_key?(options, &1)), 92 | do: @options_audio_keys, 93 | else: [] 94 | 95 | options = 96 | options 97 | |> Keyword.validate!([:video, :audio, :video_width, :video_height] ++ audio_keys) 98 | |> Map.new() 99 | 100 | if options.audio == false and options.video == false do 101 | raise "Got audio and video options set to false. At least one track must be enabled." 102 | end 103 | 104 | options 105 | end 106 | 107 | defp maybe_plug_resampler(builder, %{ 108 | audio_format: format, 109 | audio_rate: rate, 110 | audio_channels: channels 111 | }) do 112 | format = %Membrane.RawAudio{sample_format: format, sample_rate: rate, channels: channels} 113 | 114 | builder 115 | |> child(%Membrane.FFmpeg.SWResample.Converter{output_stream_format: format}) 116 | end 117 | 118 | defp maybe_plug_resampler(builder, _options) do 119 | builder 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/elixir_stream/sink.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.ElixirStream.Sink do 2 | @moduledoc false 3 | use Membrane.Sink 4 | 5 | def_input_pad :input, 6 | accepted_format: any_of(Membrane.RawAudio, Membrane.RawVideo), 7 | availability: :on_request, 8 | flow_control: :manual, 9 | demand_unit: :buffers 10 | 11 | def_options consumer: [spec: pid()] 12 | 13 | @impl true 14 | def handle_init(_ctx, opts) do 15 | {[], Map.merge(Map.from_struct(opts), %{last_pts: %{}, audio_format: nil})} 16 | end 17 | 18 | @impl true 19 | def handle_pad_added(Pad.ref(:input, kind), _ctx, state) do 20 | {[], %{state | last_pts: Map.put(state.last_pts, kind, 0)}} 21 | end 22 | 23 | @impl true 24 | def handle_playing(_ctx, state) do 25 | send(state.consumer, {:boombox_ex_stream_sink, self()}) 26 | {[], state} 27 | end 28 | 29 | @impl true 30 | def handle_info(:boombox_demand, _ctx, state) do 31 | if state.last_pts == %{} do 32 | {[], state} 33 | else 34 | {kind, _pts} = 35 | Enum.min_by(state.last_pts, fn {_kind, pts} -> pts end) 36 | 37 | {[demand: Pad.ref(:input, kind)], state} 38 | end 39 | end 40 | 41 | @impl true 42 | def handle_stream_format(Pad.ref(:input, :audio), stream_format, _ctx, state) do 43 | audio_format = %{ 44 | audio_format: stream_format.sample_format, 45 | audio_rate: stream_format.sample_rate, 46 | audio_channels: stream_format.channels 47 | } 48 | 49 | {[], %{state | audio_format: audio_format}} 50 | end 51 | 52 | @impl true 53 | def handle_stream_format(_pad, _stream_format, _ctx, state) do 54 | {[], state} 55 | end 56 | 57 | @impl true 58 | def handle_buffer(Pad.ref(:input, :video), buffer, ctx, state) do 59 | state = %{state | last_pts: %{state.last_pts | video: buffer.pts}} 60 | %{width: width, height: height} = ctx.pads[Pad.ref(:input, :video)].stream_format 61 | 62 | {:ok, image} = 63 | Vix.Vips.Image.new_from_binary(buffer.payload, width, height, 3, :VIPS_FORMAT_UCHAR) 64 | 65 | send(state.consumer, %Boombox.Packet{ 66 | payload: image, 67 | pts: buffer.pts, 68 | kind: :video 69 | }) 70 | 71 | {[], state} 72 | end 73 | 74 | @impl true 75 | def handle_buffer(Pad.ref(:input, :audio), buffer, _ctx, state) do 76 | state = %{state | last_pts: %{state.last_pts | audio: buffer.pts}} 77 | 78 | send(state.consumer, %Boombox.Packet{ 79 | payload: buffer.payload, 80 | pts: buffer.pts, 81 | kind: :audio, 82 | format: state.audio_format 83 | }) 84 | 85 | {[], state} 86 | end 87 | 88 | @impl true 89 | def handle_end_of_stream(Pad.ref(:input, kind), _ctx, state) do 90 | {[], %{state | last_pts: Map.delete(state.last_pts, kind)}} 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/elixir_stream/source.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.ElixirStream.Source do 2 | @moduledoc false 3 | use Membrane.Source 4 | 5 | def_output_pad :output, 6 | accepted_format: any_of(Membrane.RawVideo, Membrane.RawAudio), 7 | availability: :on_request, 8 | flow_control: :manual, 9 | demand_unit: :buffers 10 | 11 | def_options producer: [ 12 | spec: pid() 13 | ] 14 | 15 | @impl true 16 | def handle_init(_ctx, opts) do 17 | state = %{ 18 | producer: opts.producer, 19 | audio_format: nil, 20 | video_dims: nil 21 | } 22 | 23 | {[], state} 24 | end 25 | 26 | @impl true 27 | def handle_playing(_ctx, state) do 28 | send(state.producer, {:boombox_ex_stream_source, self()}) 29 | {[], state} 30 | end 31 | 32 | @impl true 33 | def handle_demand(Pad.ref(:output, _id), _size, _unit, ctx, state) do 34 | demands = Enum.map(ctx.pads, fn {_pad, %{demand: demand}} -> demand end) 35 | 36 | if Enum.all?(demands, &(&1 > 0)) do 37 | send(state.producer, {:boombox_demand, Enum.sum(demands)}) 38 | end 39 | 40 | {[], state} 41 | end 42 | 43 | @impl true 44 | def handle_info(%Boombox.Packet{kind: :video} = packet, _ctx, state) do 45 | image = packet.payload |> Image.flatten!() |> Image.to_colorspace!(:srgb) 46 | video_dims = %{width: Image.width(image), height: Image.height(image)} 47 | {:ok, payload} = Vix.Vips.Image.write_to_binary(image) 48 | buffer = %Membrane.Buffer{payload: payload, pts: packet.pts} 49 | 50 | if video_dims == state.video_dims do 51 | {[buffer: {Pad.ref(:output, :video), buffer}], state} 52 | else 53 | stream_format = %Membrane.RawVideo{ 54 | width: video_dims.width, 55 | height: video_dims.height, 56 | pixel_format: :RGB, 57 | aligned: true, 58 | framerate: nil 59 | } 60 | 61 | {[ 62 | stream_format: {Pad.ref(:output, :video), stream_format}, 63 | buffer: {Pad.ref(:output, :video), buffer} 64 | ], %{state | video_dims: video_dims}} 65 | end 66 | end 67 | 68 | @impl true 69 | def handle_info(%Boombox.Packet{kind: :audio} = packet, _ctx, state) do 70 | %Boombox.Packet{payload: payload, format: format} = packet 71 | buffer = %Membrane.Buffer{payload: payload, pts: packet.pts} 72 | 73 | case format do 74 | empty_format when empty_format == %{} and state.audio_format == nil -> 75 | raise "No audio stream format provided" 76 | 77 | empty_format when empty_format == %{} -> 78 | {[buffer: {Pad.ref(:output, :audio), buffer}], state} 79 | 80 | unchanged_format when unchanged_format == state.audio_format -> 81 | {[buffer: {Pad.ref(:output, :audio), buffer}], state} 82 | 83 | new_format -> 84 | stream_format = %Membrane.RawAudio{ 85 | sample_format: new_format.audio_format, 86 | sample_rate: new_format.audio_rate, 87 | channels: new_format.audio_channels 88 | } 89 | 90 | {[ 91 | stream_format: {Pad.ref(:output, :audio), stream_format}, 92 | buffer: {Pad.ref(:output, :audio), buffer} 93 | ], %{state | audio_format: format}} 94 | end 95 | end 96 | 97 | @impl true 98 | def handle_info(:boombox_eos, ctx, state) do 99 | actions = Enum.map(ctx.pads, fn {ref, _data} -> {:end_of_stream, ref} end) 100 | {actions, state} 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/hls.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.HLS do 2 | @moduledoc false 3 | 4 | import Membrane.ChildrenSpec 5 | 6 | require Membrane.Pad, as: Pad 7 | alias Boombox.InternalBin.Ready 8 | alias Membrane.H264 9 | alias Membrane.Time 10 | 11 | @spec link_output( 12 | Path.t(), 13 | [Boombox.transcoding_policy_opt()], 14 | Boombox.InternalBin.track_builders(), 15 | Membrane.ChildrenSpec.t() 16 | ) :: Ready.t() 17 | def link_output(location, opts, track_builders, spec_builder) do 18 | transcoding_policy = opts |> Keyword.get(:transcoding_policy, :if_needed) 19 | 20 | {directory, manifest_name} = 21 | if Path.extname(location) == ".m3u8" do 22 | {Path.dirname(location), Path.basename(location, ".m3u8")} 23 | else 24 | {location, "index"} 25 | end 26 | 27 | hls_mode = 28 | if Map.keys(track_builders) == [:video], do: :separate_av, else: :muxed_av 29 | 30 | spec = 31 | [ 32 | spec_builder, 33 | child( 34 | :hls_sink_bin, 35 | %Membrane.HTTPAdaptiveStream.SinkBin{ 36 | manifest_name: manifest_name, 37 | manifest_module: Membrane.HTTPAdaptiveStream.HLS, 38 | storage: %Membrane.HTTPAdaptiveStream.Storages.FileStorage{ 39 | directory: directory 40 | }, 41 | hls_mode: hls_mode, 42 | mp4_parameters_in_band?: true, 43 | target_window_duration: Membrane.Time.seconds(20) 44 | } 45 | ), 46 | Enum.map(track_builders, fn 47 | {:audio, builder} -> 48 | builder 49 | |> child(:hls_audio_transcoder, %Membrane.Transcoder{ 50 | output_stream_format: Membrane.AAC, 51 | transcoding_policy: transcoding_policy 52 | }) 53 | |> via_in(Pad.ref(:input, :audio), 54 | options: [encoding: :AAC, segment_duration: Time.milliseconds(2000)] 55 | ) 56 | |> get_child(:hls_sink_bin) 57 | 58 | {:video, builder} -> 59 | builder 60 | |> child(:hls_video_transcoder, %Membrane.Transcoder{ 61 | output_stream_format: %H264{alignment: :au, stream_structure: :avc3}, 62 | transcoding_policy: transcoding_policy 63 | }) 64 | |> via_in(Pad.ref(:input, :video), 65 | options: [encoding: :H264, segment_duration: Time.milliseconds(2000)] 66 | ) 67 | |> get_child(:hls_sink_bin) 68 | end) 69 | ] 70 | 71 | %Ready{actions: [spec: spec]} 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/pad.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.Pad do 2 | @moduledoc false 3 | 4 | import Membrane.ChildrenSpec 5 | 6 | require Membrane.Pad, as: Pad 7 | 8 | alias Boombox.InternalBin.{Ready, Wait} 9 | alias Membrane.Bin.{Action, CallbackContext} 10 | alias Membrane.Connector 11 | 12 | @type new_tracks_notification_status :: 13 | :not_resolved 14 | | :will_be_sent 15 | | :sent 16 | | :never_sent 17 | 18 | # The lifecycle of the new_tracks_notification_status and executing Boombox.InternalBin.link_output function 19 | # 20 | # BEGIN: InternalBin handle_playing 21 | # | 22 | # | 23 | # | state output != membrane_pad or 24 | # \/ there are some pads linked before handle_playing 25 | # new_tracks_notification_status == :not_resolved ----------------------------------------------------------> new_tracks_notification_status == :never_sent 26 | # | 27 | # | state.output == :membrane_pad and 28 | # | there are no pads linked during handle_playing 29 | # | 30 | # \/ 31 | # new_tracks_notification_status == :will_be_sent 32 | # | 33 | # | sending {:new_tracks, tracks} 34 | # | notification in link_output function 35 | # | 36 | # \/ 37 | # new_tracks_notification_status == :sent 38 | # | 39 | # | 40 | # | |-----------------------------------------------------------| 41 | # | | | 42 | # \/ \/ not all required pads are linked | 43 | # handle_pad_added --------------------------------------> wait on next handle_pad_added 44 | # | 45 | # | all tracks from {:new_tracks, tracks} 46 | # | have their related pad linked 47 | # | 48 | # \/ 49 | # state.status == :output_linked 50 | 51 | @spec resolve_new_tracks_notification_status( 52 | Boombox.InternalBin.State.t(), 53 | CallbackContext.t() 54 | ) :: Boombox.InternalBin.State.t() 55 | def resolve_new_tracks_notification_status(state, %{playback: :playing} = ctx) do 56 | status = 57 | if state.output == :membrane_pad and map_size(ctx.pads) == 0, 58 | do: :will_be_sent, 59 | else: :never_sent 60 | 61 | %{state | new_tracks_notification_status: status} 62 | end 63 | 64 | @spec handle_pad_added( 65 | Membrane.Pad.ref(), 66 | :audio | :video, 67 | CallbackContext.t(), 68 | Boombox.InternalBin.State.t() 69 | ) :: 70 | [Action.t()] | no_return() 71 | def handle_pad_added(pad_ref, kind, ctx, state) do 72 | if ctx.playback == :playing and state.new_tracks_notification_status != :sent do 73 | raise """ 74 | Boombox.InternalBin pad #{inspect(pad_ref)} was added while the Boombox.InternalBin playback \ 75 | is already :playing, but {:new_tracks, tracks} notification was not sent yet. 76 | 77 | You can either: 78 | * link all the pads of Boombox.InternalBin in the same spec where you spawn it, 79 | * or wait until Boombox.InternalBin returns a notification {:new_tracks, tracks} and then \ 80 | link the pads accodring to the notification. 81 | """ 82 | end 83 | 84 | spec = 85 | case Pad.name_by_ref(pad_ref) do 86 | :input -> 87 | bin_input(pad_ref) 88 | |> child({:pad_connector, :input, kind}, Connector) 89 | 90 | :output -> 91 | child({:pad_connector, :output, kind}, Connector) 92 | |> bin_output(pad_ref) 93 | end 94 | 95 | [spec: spec] 96 | end 97 | 98 | @spec create_input(CallbackContext.t()) :: Ready.t() | Wait.t() | no_return() 99 | def create_input(ctx) when ctx.playback == :playing and ctx.pads == %{} do 100 | raise """ 101 | Cannot create input of type #{inspect(__MODULE__)}, as there are no pads available. \ 102 | Link pads to Boombox.InternalBin or set the `:input` option to fix this error. 103 | """ 104 | end 105 | 106 | def create_input(ctx) when ctx.playback == :playing do 107 | track_builders = 108 | ctx.pads 109 | |> Enum.flat_map(fn 110 | {Pad.ref(:input, _id), %{options: %{kind: kind}}} -> 111 | [{kind, get_child({:pad_connector, :input, kind})}] 112 | 113 | {Pad.ref(:output, _id), _pad_data} -> 114 | [] 115 | end) 116 | |> Map.new() 117 | 118 | %Ready{track_builders: track_builders} 119 | end 120 | 121 | def create_input(ctx) when ctx.playback == :stopped do 122 | %Wait{} 123 | end 124 | 125 | @spec link_output( 126 | CallbackContext.t(), 127 | Boombox.InternalBin.track_builders(), 128 | Membrane.ChildrenSpec.t(), 129 | Boombox.InternalBin.State.t() 130 | ) :: {Ready.t() | Wait.t(), Boombox.InternalBin.State.t()} 131 | def link_output(ctx, track_builders, spec_builder, state) do 132 | case state.new_tracks_notification_status do 133 | _any when ctx.playback == :stopped -> 134 | {%Wait{}, state} 135 | 136 | :will_be_sent when ctx.playback == :playing -> 137 | actions = [notify_parent: {:new_tracks, Map.keys(track_builders)}] 138 | state = %{state | new_tracks_notification_status: :sent} 139 | {%Wait{actions: actions}, state} 140 | 141 | :sent when ctx.playback == :playing -> 142 | if map_size(ctx.pads) == map_size(track_builders), 143 | do: do_link_output(ctx, track_builders, spec_builder, state), 144 | else: {%Wait{}, state} 145 | 146 | :never_sent when ctx.playback == :playing -> 147 | do_link_output(ctx, track_builders, spec_builder, state) 148 | end 149 | end 150 | 151 | defp do_link_output(ctx, track_builders, spec_builder, state) do 152 | validate_pads_and_tracks!(ctx, track_builders) 153 | 154 | linked_tracks = 155 | track_builders 156 | |> Enum.map(fn {kind, builder} -> 157 | pad_options = 158 | ctx.pads 159 | |> Enum.find_value(fn 160 | {Pad.ref(:output, _id), %{options: %{kind: ^kind} = options}} -> options 161 | _pad_entry -> nil 162 | end) 163 | 164 | builder 165 | |> child(%Membrane.Transcoder{ 166 | output_stream_format: &resolve_stream_format(&1, pad_options, state), 167 | transcoding_policy: pad_options.transcoding_policy 168 | }) 169 | |> get_child({:pad_connector, :output, kind}) 170 | end) 171 | 172 | spec = [spec_builder, linked_tracks] 173 | {%Ready{actions: [spec: spec]}, state} 174 | end 175 | 176 | defp resolve_stream_format(%input_codec{}, %{kind: pad_kind, codec: pad_codec}, state) do 177 | default_codec = 178 | case pad_kind do 179 | :audio -> if webrtc_input?(state), do: Membrane.Opus, else: Membrane.AAC 180 | :video -> Membrane.H264 181 | end 182 | 183 | pad_codecs = Bunch.listify(pad_codec || default_codec) 184 | 185 | if input_codec in pad_codecs, 186 | do: input_codec, 187 | else: List.first(pad_codecs) 188 | end 189 | 190 | defp validate_pads_and_tracks!(ctx, track_builders) do 191 | output_pads = 192 | ctx.pads 193 | |> Enum.filter(fn {Pad.ref(name, _id), _data} -> 194 | name == :output 195 | end) 196 | |> Map.new(fn {pad_ref, %{options: %{kind: kind}}} -> 197 | {kind, pad_ref} 198 | end) 199 | 200 | raise_if_key_not_present(track_builders, output_pads, fn kind -> 201 | "Boombox.Bin has no output pad of kind #{inspect(kind)}, but it has a track \ 202 | of this kind. Please add an output pad of this kind to the Boombox.Bin." 203 | end) 204 | 205 | raise_if_key_not_present(output_pads, track_builders, fn kind -> 206 | "Boombox.Bin has no track of kind #{inspect(kind)}, but it has an output pad \ 207 | of this kind. Please add a track of this kind to the Boombox.Bin." 208 | end) 209 | end 210 | 211 | defp raise_if_key_not_present(map_from, map_in, error_log_generator) do 212 | map_from 213 | |> Enum.each(fn {kind, _value} -> 214 | if not is_map_key(map_in, kind) do 215 | raise error_log_generator.(kind) 216 | end 217 | end) 218 | end 219 | 220 | defp webrtc_input?(state) do 221 | case state.input do 222 | {:webrtc, _signaling} -> true 223 | {:webrtc, _signaling, _opts} -> true 224 | _other -> false 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/rtmp.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.RTMP do 2 | @moduledoc false 3 | 4 | import Membrane.ChildrenSpec 5 | require Membrane.Logger 6 | alias Boombox.InternalBin.{Ready, Wait} 7 | alias Membrane.{RTMP, RTMPServer} 8 | 9 | @spec create_input(String.t() | pid(), pid()) :: Wait.t() 10 | def create_input(client_ref, _utility_supervisor) when is_pid(client_ref) do 11 | handle_connection(client_ref) 12 | end 13 | 14 | def create_input(uri, utility_supervisor) do 15 | {use_ssl?, port, target_app, target_stream_key} = RTMPServer.parse_url(uri) 16 | 17 | boombox = self() 18 | 19 | handle_new_client = fn client_ref, app, stream_key -> 20 | if app == target_app and stream_key == target_stream_key do 21 | send(boombox, {:rtmp_client_ref, client_ref}) 22 | Membrane.RTMP.Source.ClientHandlerImpl 23 | else 24 | Membrane.Logger.warning("Unexpected client connected on /#{app}/#{stream_key}") 25 | end 26 | end 27 | 28 | server_options = %{ 29 | port: port, 30 | use_ssl?: use_ssl?, 31 | handle_new_client: handle_new_client, 32 | client_timeout: Membrane.Time.seconds(60) 33 | } 34 | 35 | {:ok, _server} = 36 | Membrane.UtilitySupervisor.start_link_child( 37 | utility_supervisor, 38 | {RTMPServer, server_options} 39 | ) 40 | 41 | %Wait{actions: [notify_parent: :external_resource_ready]} 42 | end 43 | 44 | @spec handle_connection(pid()) :: Ready.t() 45 | def handle_connection(client_ref) do 46 | spec = 47 | child(:rtmp_source, %RTMP.SourceBin{client_ref: client_ref}) 48 | |> via_out(:audio) 49 | |> child(:rtmp_in_aac_parser, Membrane.AAC.Parser) 50 | 51 | track_builders = %{ 52 | audio: get_child(:rtmp_in_aac_parser), 53 | video: get_child(:rtmp_source) |> via_out(:video) 54 | } 55 | 56 | %Ready{spec_builder: spec, track_builders: track_builders} 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/rtp.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.RTP do 2 | @moduledoc false 3 | import Membrane.ChildrenSpec 4 | 5 | require Membrane.Pad 6 | 7 | alias Boombox.InternalBin.Ready 8 | alias Membrane.RTP 9 | 10 | @supported_encodings [audio: [:AAC, :Opus], video: [:H264, :H265]] 11 | 12 | @encoding_specific_params_specs %{ 13 | input: %{ 14 | AAC: [aac_bitrate_mode: [require?: true], audio_specific_config: [require?: true]], 15 | H264: [pps: [require?: false, default: nil], sps: [require?: false, default: nil]], 16 | H265: [ 17 | vps: [require?: false, default: nil], 18 | pps: [require?: false, default: nil], 19 | sps: [require?: false, default: nil] 20 | ] 21 | }, 22 | output: %{ 23 | AAC: [aac_bitrate_mode: [require?: true]] 24 | } 25 | } 26 | 27 | @type parsed_input_encoding_specific_params :: 28 | %{aac_bitrate_mode: RTP.AAC.Utils.mode(), audio_specific_config: binary()} 29 | | %{vps: binary() | nil, pps: binary() | nil, sps: binary() | nil} 30 | | %{pps: binary() | nil, sps: binary() | nil} 31 | | %{} 32 | 33 | @type parsed_output_encoding_specific_params :: 34 | %{aac_bitrate_mode: RTP.AAC.Utils.mode()} 35 | | %{} 36 | 37 | @type parsed_input_track_config :: %{ 38 | encoding_name: RTP.encoding_name(), 39 | encoding_specific_params: parsed_input_encoding_specific_params(), 40 | payload_type: RTP.payload_type(), 41 | clock_rate: RTP.clock_rate() 42 | } 43 | 44 | @type parsed_output_track_config :: %{ 45 | encoding_name: RTP.encoding_name(), 46 | encoding_specific_params: parsed_output_encoding_specific_params(), 47 | payload_type: RTP.payload_type(), 48 | clock_rate: RTP.clock_rate() 49 | } 50 | 51 | @type parsed_in_opts :: %{ 52 | port: :inet.port_number(), 53 | track_configs: %{ 54 | optional(:audio) => parsed_input_track_config(), 55 | optional(:video) => parsed_input_track_config() 56 | } 57 | } 58 | 59 | @type parsed_out_opts :: %{ 60 | address: :inet.ip_address(), 61 | port: :inet.port_number(), 62 | track_configs: %{ 63 | optional(:audio) => parsed_output_track_config(), 64 | optional(:video) => parsed_output_track_config() 65 | }, 66 | transcoding_policy: :always | :if_needed | :never 67 | } 68 | 69 | @type parsed_track_config :: parsed_input_track_config() | parsed_output_track_config() 70 | 71 | @spec create_input(Boombox.in_rtp_opts()) :: Ready.t() 72 | def create_input(opts) do 73 | parsed_options = validate_and_parse_options(:input, opts) 74 | payload_type_mapping = get_payload_type_mapping(parsed_options.track_configs) 75 | 76 | spec = 77 | child(:udp_source, %Membrane.UDP.Source{local_port_no: opts[:port]}) 78 | |> child(:rtp_demuxer, %Membrane.RTP.Demuxer{payload_type_mapping: payload_type_mapping}) 79 | 80 | track_builders = 81 | Map.new(parsed_options.track_configs, fn {media_type, track_config} -> 82 | {depayloader, parser} = 83 | case track_config.encoding_name do 84 | :H264 -> 85 | ppss = List.wrap(track_config.encoding_specific_params.pps) 86 | spss = List.wrap(track_config.encoding_specific_params.sps) 87 | {Membrane.RTP.H264.Depayloader, %Membrane.H264.Parser{ppss: ppss, spss: spss}} 88 | 89 | :AAC -> 90 | audio_specific_config = track_config.encoding_specific_params.audio_specific_config 91 | bitrate_mode = track_config.encoding_specific_params.aac_bitrate_mode 92 | 93 | {%Membrane.RTP.AAC.Depayloader{mode: bitrate_mode}, 94 | %Membrane.AAC.Parser{audio_specific_config: audio_specific_config}} 95 | 96 | :OPUS -> 97 | {Membrane.RTP.Opus.Depayloader, Membrane.Opus.Parser} 98 | 99 | :H265 -> 100 | vpss = List.wrap(track_config.encoding_specific_params.vps) 101 | ppss = List.wrap(track_config.encoding_specific_params.pps) 102 | spss = List.wrap(track_config.encoding_specific_params.sps) 103 | 104 | {Membrane.RTP.H265.Depayloader, 105 | %Membrane.H265.Parser{vpss: vpss, ppss: ppss, spss: spss}} 106 | end 107 | 108 | spec = 109 | get_child(:rtp_demuxer) 110 | |> via_out(:output, options: [stream_id: {:encoding_name, track_config.encoding_name}]) 111 | |> child({:jitter_buffer, media_type}, %Membrane.RTP.JitterBuffer{ 112 | clock_rate: track_config.clock_rate 113 | }) 114 | |> child({:rtp_depayloader, media_type}, depayloader) 115 | |> child({:rtp_in_parser, media_type}, parser) 116 | 117 | {media_type, spec} 118 | end) 119 | 120 | %Ready{spec_builder: spec, track_builders: track_builders} 121 | end 122 | 123 | @spec link_output( 124 | Boombox.out_rtp_opts(), 125 | Boombox.InternalBin.track_builders(), 126 | Membrane.ChildrenSpec.t() 127 | ) :: Ready.t() 128 | def link_output(opts, track_builders, spec_builder) do 129 | parsed_opts = validate_and_parse_options(:output, opts) 130 | 131 | spec = [ 132 | spec_builder, 133 | child(:rtp_muxer, Membrane.RTP.Muxer) 134 | |> child(:udp_rtp_sink, %Membrane.UDP.Sink{ 135 | destination_address: parsed_opts.address, 136 | destination_port_no: parsed_opts.port 137 | }), 138 | Enum.map(track_builders, fn {media_type, builder} -> 139 | track_config = parsed_opts.track_configs[media_type] 140 | 141 | {output_stream_format, parser, payloader} = 142 | case track_config.encoding_name do 143 | :H264 -> 144 | {%Membrane.H264{stream_structure: :annexb, alignment: :nalu}, 145 | %Membrane.H264.Parser{output_stream_structure: :annexb, output_alignment: :nalu}, 146 | Membrane.RTP.H264.Payloader} 147 | 148 | :AAC -> 149 | {%Membrane.AAC{encapsulation: :none}, 150 | %Membrane.AAC.Parser{out_encapsulation: :none}, 151 | %Membrane.RTP.AAC.Payloader{ 152 | mode: track_config.encoding_specific_params.aac_bitrate_mode, 153 | frames_per_packet: 1 154 | }} 155 | 156 | :OPUS -> 157 | {Membrane.Opus, %Membrane.Opus.Parser{delimitation: :undelimit}, 158 | Membrane.RTP.Opus.Payloader} 159 | 160 | :H265 -> 161 | {%Membrane.H265{stream_structure: :annexb, alignment: :nalu}, 162 | %Membrane.H265.Parser{output_stream_structure: :annexb, output_alignment: :nalu}, 163 | Membrane.RTP.H265.Payloader} 164 | end 165 | 166 | builder 167 | |> child({:rtp_transcoder, media_type}, %Membrane.Transcoder{ 168 | output_stream_format: output_stream_format, 169 | transcoding_policy: parsed_opts.transcoding_policy 170 | }) 171 | |> child({:rtp_out_parser, media_type}, parser) 172 | |> child({:rtp_payloader, media_type}, payloader) 173 | |> child({:realtimer, media_type}, Membrane.Realtimer) 174 | |> via_in(:input, 175 | options: [ 176 | encoding: track_config.encoding_name, 177 | payload_type: track_config.payload_type, 178 | clock_rate: track_config.clock_rate 179 | ] 180 | ) 181 | |> get_child(:rtp_muxer) 182 | end) 183 | ] 184 | 185 | %Ready{actions: [spec: spec]} 186 | end 187 | 188 | @spec validate_and_parse_options(:input, Boombox.in_rtp_opts()) :: parsed_in_opts() 189 | @spec validate_and_parse_options(:output, Boombox.out_rtp_opts()) :: parsed_out_opts() 190 | defp validate_and_parse_options(direction, opts) do 191 | transport_opts = 192 | case direction do 193 | :input -> 194 | if Keyword.has_key?(opts, :port) do 195 | %{port: opts[:port]} 196 | else 197 | raise "Receiving port not specified in RTP options" 198 | end 199 | 200 | :output -> 201 | parse_output_target(opts) 202 | end 203 | 204 | parsed_track_configs = 205 | [:audio, :video] 206 | |> Enum.filter(fn 207 | :video -> opts[:video_encoding] != nil 208 | :audio -> opts[:audio_encoding] != nil 209 | end) 210 | |> Map.new(fn media_type -> 211 | {media_type, validate_and_parse_track_config(direction, media_type, opts)} 212 | end) 213 | 214 | if parsed_track_configs == %{} do 215 | raise "No RTP media configured" 216 | end 217 | 218 | parsed_opts = transport_opts |> Map.put(:track_configs, parsed_track_configs) 219 | 220 | case direction do 221 | :input -> 222 | parsed_opts 223 | 224 | :output -> 225 | transcoding_policy = opts |> Keyword.get(:transcoding_policy, :if_needed) 226 | parsed_opts |> Map.put(:transcoding_policy, transcoding_policy) 227 | end 228 | end 229 | 230 | @spec parse_output_target( 231 | target: String.t(), 232 | address: :inet.ip4_address() | String.t(), 233 | port: :inet.port_number() 234 | ) :: %{address: :inet.ip4_address(), port: :inet.port_number()} 235 | defp parse_output_target(opts) do 236 | case Map.new(opts) do 237 | %{target: target} -> 238 | [address_string, port_string] = String.split(target, ":") 239 | {:ok, address} = :inet.parse_ipv4_address(String.to_charlist(address_string)) 240 | %{address: address, port: String.to_integer(port_string)} 241 | 242 | %{address: address, port: port} when is_binary(address) -> 243 | {:ok, address} = :inet.parse_ipv4_address(String.to_charlist(address)) 244 | %{address: address, port: port} 245 | 246 | %{address: address, port: port} when is_tuple(address) -> 247 | %{address: address, port: port} 248 | 249 | _invalid_target -> 250 | raise "RTP output target address and port not specified" 251 | end 252 | end 253 | 254 | @spec validate_and_parse_track_config(:input, :video | :audio, Boombox.in_rtp_opts()) :: 255 | parsed_input_track_config() 256 | @spec validate_and_parse_track_config(:output, :video | :audio, Boombox.out_rtp_opts()) :: 257 | parsed_output_track_config() 258 | defp validate_and_parse_track_config(direction, media_type, opts) do 259 | {encoding_name, payload_type, clock_rate} = 260 | case media_type do 261 | :audio -> {opts[:audio_encoding], opts[:audio_payload_type], opts[:audio_clock_rate]} 262 | :video -> {opts[:video_encoding], opts[:video_payload_type], opts[:video_clock_rate]} 263 | end 264 | 265 | if encoding_name not in @supported_encodings[media_type] do 266 | raise "Encoding #{inspect(encoding_name)} for #{inspect(media_type)} media type not supported" 267 | end 268 | 269 | %{ 270 | payload_format: %RTP.PayloadFormat{encoding_name: encoding_name}, 271 | payload_type: payload_type, 272 | clock_rate: clock_rate 273 | } = 274 | RTP.PayloadFormat.resolve( 275 | encoding_name: encoding_name, 276 | payload_type: payload_type, 277 | clock_rate: clock_rate 278 | ) 279 | 280 | if payload_type == nil do 281 | raise "payload_type for encoding #{inspect(encoding_name)} not provided with no default value registered" 282 | end 283 | 284 | if clock_rate == nil do 285 | raise "clock_rate for encoding #{inspect(encoding_name)} and payload_type #{inspect(payload_type)} not provided with no default value registered" 286 | end 287 | 288 | encoding_specific_params_specs = 289 | Map.get(@encoding_specific_params_specs[direction], encoding_name, []) 290 | 291 | {:ok, encoding_specific_params} = 292 | Keyword.intersect(encoding_specific_params_specs, opts) 293 | |> Bunch.Config.parse(encoding_specific_params_specs) 294 | 295 | %{ 296 | encoding_name: encoding_name, 297 | encoding_specific_params: encoding_specific_params, 298 | payload_type: payload_type, 299 | clock_rate: clock_rate 300 | } 301 | end 302 | 303 | @spec get_payload_type_mapping(%{audio: parsed_track_config(), video: parsed_track_config()}) :: 304 | RTP.PayloadFormat.payload_type_mapping() 305 | defp get_payload_type_mapping(track_configs) do 306 | Map.new(track_configs, fn {_media_type, track_config} -> 307 | {track_config.payload_type, 308 | %{encoding_name: track_config.encoding_name, clock_rate: track_config.clock_rate}} 309 | end) 310 | end 311 | end 312 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/rtsp.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.RTSP do 2 | @moduledoc false 3 | import Membrane.ChildrenSpec 4 | 5 | require Membrane.Pad 6 | 7 | require Membrane.Logger 8 | alias Membrane.{RTP, RTSP} 9 | alias Boombox.InternalBin.{Ready, State, Wait} 10 | 11 | @type state :: %{ 12 | set_up_tracks: %{ 13 | optional(:audio) => Membrane.RTSP.Source.track(), 14 | optional(:video) => Membrane.RTSP.Source.track() 15 | }, 16 | tracks_left_to_link: non_neg_integer(), 17 | track_builders: Boombox.InternalBin.track_builders() 18 | } 19 | 20 | @spec create_input(URI.t()) :: Wait.t() 21 | def create_input(uri) do 22 | port = Enum.random(5_000..65_000) 23 | 24 | spec = 25 | child(:rtsp_source, %RTSP.Source{ 26 | transport: {:udp, port, port + 20}, 27 | allowed_media_types: [:video, :audio], 28 | stream_uri: uri, 29 | on_connection_closed: :send_eos 30 | }) 31 | 32 | %Wait{actions: [spec: spec]} 33 | end 34 | 35 | @spec handle_set_up_tracks([RTSP.Source.track()], State.t()) :: {Wait.t(), State.t()} 36 | def handle_set_up_tracks(tracks, state) do 37 | rtsp_state = %{ 38 | set_up_tracks: Map.new(tracks, fn track -> {track.type, track} end), 39 | tracks_left_to_link: length(tracks), 40 | track_builders: %{} 41 | } 42 | 43 | {%Wait{}, %{state | rtsp_state: rtsp_state}} 44 | end 45 | 46 | @spec handle_input_track(RTP.ssrc(), RTSP.Source.track(), State.t()) :: 47 | {Ready.t() | Wait.t(), State.t()} 48 | def handle_input_track(ssrc, track, state) do 49 | track_builders = state.rtsp_state.track_builders 50 | 51 | {spec, track_builders} = 52 | case track do 53 | %{type: type} when is_map_key(track_builders, type) -> 54 | Membrane.Logger.warning( 55 | "Tried to link a track of type #{inspect(type)}, but another track 56 | of that type has already been received" 57 | ) 58 | 59 | spec = 60 | get_child(:rtsp_source) 61 | |> via_out(Membrane.Pad.ref(:output, ssrc)) 62 | 63 | {spec, track_builders} 64 | 65 | %{rtpmap: %{encoding: "H264"}} -> 66 | {spss, ppss} = 67 | case track.fmtp.sprop_parameter_sets do 68 | nil -> {[], []} 69 | parameter_sets -> {parameter_sets.sps, parameter_sets.pps} 70 | end 71 | 72 | video_spec = 73 | get_child(:rtsp_source) 74 | |> via_out(Membrane.Pad.ref(:output, ssrc)) 75 | |> child(:rtsp_in_h264_parser, %Membrane.H264.Parser{spss: spss, ppss: ppss}) 76 | 77 | {[], Map.put(track_builders, :video, video_spec)} 78 | 79 | %{rtpmap: %{encoding: "mpeg4-generic"}, type: :audio} -> 80 | audio_spec = 81 | get_child(:rtsp_source) 82 | |> via_out(Membrane.Pad.ref(:output, ssrc)) 83 | |> child(:rtsp_in_aac_parser, Membrane.AAC.Parser) 84 | 85 | {[], Map.put(track_builders, :audio, audio_spec)} 86 | 87 | %{rtpmap: %{encoding: unsupported_encoding}} -> 88 | raise "Received unsupported encoding with RTSP: #{inspect(unsupported_encoding)}" 89 | end 90 | 91 | state = 92 | state 93 | |> Bunch.Struct.put_in([:rtsp_state, :track_builders], track_builders) 94 | |> Bunch.Struct.update_in([:rtsp_state, :tracks_left_to_link], &(&1 - 1)) 95 | 96 | if state.rtsp_state.tracks_left_to_link == 0 do 97 | {%Ready{actions: [spec: spec], track_builders: track_builders}, state} 98 | else 99 | {%Wait{actions: [spec: spec]}, state} 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/storage_endpoints.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.StorageEndpoints do 2 | @moduledoc false 3 | import Membrane.ChildrenSpec 4 | 5 | defguard is_storage_endpoint_type(endpoint_type) 6 | when endpoint_type in [:mp4, :h264, :h265, :aac, :wav, :mp3, :ivf, :ogg] 7 | 8 | defguard is_storage_endpoint_extension(extension) 9 | when extension in [".mp4", ".h264", ".h265", ".aac", ".wav", ".mp3", ".ivf", ".ogg"] 10 | 11 | @spec get_storage_endpoint_type!(String.t()) :: atom() | no_return() 12 | def get_storage_endpoint_type!("." <> type) do 13 | String.to_existing_atom(type) 14 | end 15 | 16 | @spec get_source(String.t(), :file | :http, boolean()) :: Membrane.ChildrenSpec.builder() 17 | def get_source(location, transport, seekable \\ false) 18 | 19 | def get_source(location, :file, seekable) do 20 | child(:file_source, %Membrane.File.Source{location: location, seekable?: seekable}) 21 | end 22 | 23 | def get_source(location, :http, _seekable) do 24 | child(:http_source, %Membrane.Hackney.Source{ 25 | location: location, 26 | hackney_opts: [follow_redirect: true] 27 | }) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/storage_endpoints/aac.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.StorageEndpoints.AAC do 2 | @moduledoc false 3 | import Membrane.ChildrenSpec 4 | alias Boombox.InternalBin.Ready 5 | alias Boombox.InternalBin.StorageEndpoints 6 | alias Membrane.AAC 7 | 8 | @spec create_input(String.t(), transport: :file | :http) :: Ready.t() 9 | def create_input(location, opts) do 10 | spec = 11 | StorageEndpoints.get_source(location, opts[:transport]) 12 | |> child(:aac_input_parser, Membrane.AAC.Parser) 13 | 14 | %Ready{track_builders: %{audio: spec}} 15 | end 16 | 17 | @spec link_output( 18 | String.t(), 19 | [Boombox.transcoding_policy_opt()], 20 | Boombox.InternalBin.track_builders(), 21 | Membrane.ChildrenSpec.t() 22 | ) :: Ready.t() 23 | def link_output(location, opts, track_builders, _spec_builder) do 24 | transcoding_policy = opts |> Keyword.get(:transcoding_policy, :if_needed) 25 | 26 | spec = 27 | track_builders[:audio] 28 | |> child(:aac_audio_transcoder, %Membrane.Transcoder{ 29 | output_stream_format: AAC, 30 | transcoding_policy: transcoding_policy 31 | }) 32 | |> child(:file_sink, %Membrane.File.Sink{location: location}) 33 | 34 | %Ready{actions: [spec: spec]} 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/storage_endpoints/h264.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.StorageEndpoints.H264 do 2 | @moduledoc false 3 | import Membrane.ChildrenSpec 4 | alias Boombox.InternalBin.Ready 5 | alias Boombox.InternalBin.StorageEndpoints 6 | alias Membrane.H264 7 | 8 | @spec create_input(String.t(), transport: :file | :http, framerate: Membrane.H264.framerate()) :: 9 | Ready.t() 10 | def create_input(location, opts) do 11 | spec = 12 | StorageEndpoints.get_source(location, opts[:transport]) 13 | |> child(:h264_parser, %Membrane.H264.Parser{ 14 | output_alignment: :au, 15 | generate_best_effort_timestamps: %{framerate: opts[:framerate]}, 16 | output_stream_structure: :annexb 17 | }) 18 | 19 | %Ready{track_builders: %{video: spec}} 20 | end 21 | 22 | @spec link_output( 23 | String.t(), 24 | [Boombox.transcoding_policy_opt()], 25 | Boombox.InternalBin.track_builders(), 26 | Membrane.ChildrenSpec.t() 27 | ) :: Ready.t() 28 | def link_output(location, opts, track_builders, _spec_builder) do 29 | transcoding_policy = opts |> Keyword.get(:transcoding_policy, :if_needed) 30 | 31 | spec = 32 | track_builders[:video] 33 | |> child(:h264_video_transcoder, %Membrane.Transcoder{ 34 | output_stream_format: %H264{stream_structure: :annexb}, 35 | transcoding_policy: transcoding_policy 36 | }) 37 | |> child(:file_sink, %Membrane.File.Sink{location: location}) 38 | 39 | %Ready{actions: [spec: spec]} 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/storage_endpoints/h265.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.StorageEndpoints.H265 do 2 | @moduledoc false 3 | import Membrane.ChildrenSpec 4 | alias Boombox.InternalBin.Ready 5 | alias Boombox.InternalBin.StorageEndpoints 6 | alias Membrane.H265 7 | 8 | @spec create_input(String.t(), transport: :file | :http, framerate: H265.framerate_t()) :: 9 | Ready.t() 10 | def create_input(location, opts) do 11 | spec = 12 | StorageEndpoints.get_source(location, opts[:transport]) 13 | |> child(:h265_parser, %H265.Parser{ 14 | output_alignment: :au, 15 | generate_best_effort_timestamps: %{framerate: opts[:framerate]}, 16 | output_stream_structure: :annexb 17 | }) 18 | 19 | %Ready{track_builders: %{video: spec}} 20 | end 21 | 22 | @spec link_output( 23 | String.t(), 24 | [Boombox.transcoding_policy_opt()], 25 | Boombox.InternalBin.track_builders(), 26 | Membrane.ChildrenSpec.t() 27 | ) :: Ready.t() 28 | def link_output(location, opts, track_builders, _spec_builder) do 29 | transcoding_policy = opts |> Keyword.get(:transcoding_policy, :if_needed) 30 | 31 | spec = 32 | track_builders[:video] 33 | |> child(:h265_video_transcoder, %Membrane.Transcoder{ 34 | output_stream_format: %H265{stream_structure: :annexb}, 35 | transcoding_policy: transcoding_policy 36 | }) 37 | |> child(:file_sink, %Membrane.File.Sink{location: location}) 38 | 39 | %Ready{actions: [spec: spec]} 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/storage_endpoints/ivf.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.StorageEndpoints.IVF do 2 | @moduledoc false 3 | import Membrane.ChildrenSpec 4 | alias Boombox.InternalBin.Ready 5 | alias Boombox.InternalBin.StorageEndpoints 6 | alias Membrane.{VP8, VP9} 7 | 8 | @spec create_input(String.t(), transport: :file | :http) :: Ready.t() 9 | def create_input(location, opts) do 10 | spec = 11 | StorageEndpoints.get_source(location, opts[:transport]) 12 | |> child(:ivf_deserializer, Membrane.IVF.Deserializer) 13 | 14 | %Ready{track_builders: %{video: spec}} 15 | end 16 | 17 | @spec link_output( 18 | String.t(), 19 | [Boombox.transcoding_policy_opt()], 20 | Boombox.InternalBin.track_builders(), 21 | Membrane.ChildrenSpec.t() 22 | ) :: Ready.t() 23 | def link_output(location, opts, track_builders, _spec_builder) do 24 | transcoding_policy = opts |> Keyword.get(:transcoding_policy, :if_needed) 25 | 26 | spec = 27 | track_builders[:video] 28 | |> child(:ivf_video_transcoder, %Membrane.Transcoder{ 29 | output_stream_format: fn 30 | %VP8{} -> VP8 31 | %Membrane.RemoteStream{content_format: VP8} -> VP8 32 | %VP9{} -> VP9 33 | _other -> VP9 34 | end, 35 | transcoding_policy: transcoding_policy 36 | }) 37 | |> child(:ivf_serializer, Membrane.IVF.Serializer) 38 | |> child(:file_sink, %Membrane.File.Sink{location: location}) 39 | 40 | %Ready{actions: [spec: spec]} 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/storage_endpoints/mp3.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.StorageEndpoints.MP3 do 2 | @moduledoc false 3 | import Membrane.ChildrenSpec 4 | alias Boombox.InternalBin.Ready 5 | alias Boombox.InternalBin.StorageEndpoints 6 | 7 | @spec create_input(String.t(), transport: :file | :http) :: Ready.t() 8 | def create_input(location, opts) do 9 | spec = 10 | StorageEndpoints.get_source(location, opts[:transport]) 11 | # transcoder is used just to ensure that a proper MPEGAudio stream format is resolved 12 | # as there is no MP3 parser that could do it 13 | |> child(:mp3_stream_format_overrider, %Membrane.Transcoder{ 14 | output_stream_format: Membrane.MPEGAudio, 15 | assumed_input_stream_format: %Membrane.RemoteStream{ 16 | content_format: Membrane.MPEGAudio 17 | } 18 | }) 19 | 20 | %Ready{track_builders: %{audio: spec}} 21 | end 22 | 23 | @spec link_output( 24 | String.t(), 25 | [Boombox.transcoding_policy_opt()], 26 | Boombox.InternalBin.track_builders(), 27 | Membrane.ChildrenSpec.t() 28 | ) :: Ready.t() 29 | def link_output(location, opts, track_builders, _spec_builder) do 30 | transcoding_policy = opts |> Keyword.get(:transcoding_policy, :if_needed) 31 | 32 | spec = 33 | track_builders[:audio] 34 | |> child(:mp3_audio_transcoder, %Membrane.Transcoder{ 35 | output_stream_format: Membrane.MPEGAudio, 36 | transcoding_policy: transcoding_policy 37 | }) 38 | |> child(:file_sink, %Membrane.File.Sink{location: location}) 39 | 40 | %Ready{actions: [spec: spec]} 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/storage_endpoints/mp4.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.StorageEndpoints.MP4 do 2 | @moduledoc false 3 | 4 | import Membrane.ChildrenSpec 5 | require Membrane.Pad, as: Pad 6 | alias Boombox.InternalBin.{Ready, Wait} 7 | alias Boombox.InternalBin.StorageEndpoints 8 | alias Membrane.H264 9 | alias Membrane.H265 10 | 11 | defguardp is_h26x(format) when is_struct(format) and format.__struct__ in [H264, H265] 12 | 13 | @spec create_input(String.t(), [{:transport, :file | :http}]) :: 14 | Wait.t() 15 | def create_input(location, opts) do 16 | optimize_for_non_fast_start = opts[:transport] == :file 17 | 18 | spec = 19 | StorageEndpoints.get_source(location, opts[:transport], optimize_for_non_fast_start) 20 | |> child(:mp4_demuxer, %Membrane.MP4.Demuxer.ISOM{ 21 | optimize_for_non_fast_start?: optimize_for_non_fast_start 22 | }) 23 | 24 | %Wait{actions: [spec: spec]} 25 | end 26 | 27 | @spec handle_input_tracks(Membrane.MP4.Demuxer.ISOM.new_tracks_t()) :: Ready.t() 28 | def handle_input_tracks(tracks) do 29 | track_builders = 30 | Map.new(tracks, fn 31 | {id, %Membrane.AAC{}} -> 32 | spec = 33 | get_child(:mp4_demuxer) 34 | |> via_out(Pad.ref(:output, id)) 35 | |> child(:mp4_in_aac_parser, Membrane.AAC.Parser) 36 | 37 | {:audio, spec} 38 | 39 | {id, video_format} when is_h26x(video_format) -> 40 | spec = 41 | get_child(:mp4_demuxer) 42 | |> via_out(Pad.ref(:output, id)) 43 | 44 | {:video, spec} 45 | end) 46 | 47 | %Ready{track_builders: track_builders} 48 | end 49 | 50 | @spec link_output( 51 | String.t(), 52 | [Boombox.transcoding_policy_opt()], 53 | Boombox.InternalBin.track_builders(), 54 | Membrane.ChildrenSpec.t() 55 | ) :: Ready.t() 56 | def link_output(location, opts, track_builders, spec_builder) do 57 | transcoding_policy = opts |> Keyword.get(:transcoding_policy, :if_needed) 58 | 59 | audio_branch = 60 | case track_builders[:audio] do 61 | nil -> 62 | [] 63 | 64 | audio_builder -> 65 | [ 66 | audio_builder 67 | |> child(:mp4_audio_transcoder, %Membrane.Transcoder{ 68 | output_stream_format: Membrane.AAC, 69 | transcoding_policy: transcoding_policy 70 | }) 71 | |> child(:mp4_out_aac_parser, %Membrane.AAC.Parser{ 72 | out_encapsulation: :none, 73 | output_config: :esds 74 | }) 75 | |> via_in(Pad.ref(:input, :audio)) 76 | |> get_child(:mp4_muxer) 77 | ] 78 | end 79 | 80 | video_branch = 81 | case track_builders[:video] do 82 | nil -> 83 | [] 84 | 85 | video_builder -> 86 | [ 87 | video_builder 88 | |> child(:mp4_video_transcoder, %Membrane.Transcoder{ 89 | output_stream_format: fn 90 | %H264{stream_structure: :annexb} = h264 -> 91 | %H264{h264 | stream_structure: :avc3, alignment: :au} 92 | 93 | %H265{stream_structure: :annexb} = h265 -> 94 | %H265{h265 | stream_structure: :hev1, alignment: :au} 95 | 96 | h26x when is_h26x(h26x) -> 97 | %{h26x | alignment: :au} 98 | 99 | _not_h26x -> 100 | %H264{stream_structure: :avc3, alignment: :au} 101 | end, 102 | transcoding_policy: transcoding_policy 103 | }) 104 | |> via_in(Pad.ref(:input, :video)) 105 | |> get_child(:mp4_muxer) 106 | ] 107 | end 108 | 109 | spec = 110 | [ 111 | spec_builder, 112 | child(:mp4_muxer, Membrane.MP4.Muxer.ISOM) 113 | |> child(:file_sink, %Membrane.File.Sink{location: location}) 114 | ] ++ audio_branch ++ video_branch 115 | 116 | %Ready{actions: [spec: spec]} 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/storage_endpoints/ogg.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.StorageEndpoints.Ogg do 2 | @moduledoc false 3 | import Membrane.ChildrenSpec 4 | alias Boombox.InternalBin.Ready 5 | alias Boombox.InternalBin.StorageEndpoints 6 | 7 | @spec create_input(String.t(), transport: :file | :http) :: Ready.t() 8 | def create_input(location, opts) do 9 | spec = 10 | StorageEndpoints.get_source(location, opts[:transport]) 11 | |> child(:ogg_demuxer, Membrane.Ogg.Demuxer) 12 | 13 | %Ready{track_builders: %{audio: spec}} 14 | end 15 | 16 | @spec link_output( 17 | String.t(), 18 | [Boombox.transcoding_policy_opt()], 19 | Boombox.InternalBin.track_builders(), 20 | Membrane.ChildrenSpec.t() 21 | ) :: Ready.t() 22 | def link_output(location, opts, track_builders, _spec_builder) do 23 | transcoding_policy = opts |> Keyword.get(:transcoding_policy, :if_needed) 24 | 25 | spec = 26 | track_builders[:audio] 27 | |> child(:ogg_audio_transcoder, %Membrane.Transcoder{ 28 | output_stream_format: Membrane.Opus, 29 | transcoding_policy: transcoding_policy 30 | }) 31 | |> child(:parser, %Membrane.Opus.Parser{ 32 | generate_best_effort_timestamps?: true, 33 | delimitation: :undelimit, 34 | input_delimitted?: false 35 | }) 36 | |> child(:ogg_muxer, Membrane.Ogg.Muxer) 37 | |> child(:file_sink, %Membrane.File.Sink{location: location}) 38 | 39 | %Ready{actions: [spec: spec]} 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/storage_endpoints/wav.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.StorageEndpoints.WAV do 2 | @moduledoc false 3 | import Membrane.ChildrenSpec 4 | alias Boombox.InternalBin.Ready 5 | alias Boombox.InternalBin.StorageEndpoints 6 | 7 | @spec create_input(String.t(), transport: :file | :http) :: Ready.t() 8 | def create_input(location, opts) do 9 | spec = 10 | StorageEndpoints.get_source(location, opts[:transport]) 11 | |> child(:wav_input_parser, Membrane.WAV.Parser) 12 | 13 | %Ready{track_builders: %{audio: spec}} 14 | end 15 | 16 | @spec link_output( 17 | String.t(), 18 | [Boombox.transcoding_policy_opt()], 19 | Boombox.InternalBin.track_builders(), 20 | Membrane.ChildrenSpec.t() 21 | ) :: Ready.t() 22 | def link_output(location, opts, track_builders, _spec_builder) do 23 | transcoding_policy = opts |> Keyword.get(:transcoding_policy, :if_needed) 24 | 25 | spec = 26 | track_builders[:audio] 27 | |> child(:wav_transcoder, %Membrane.Transcoder{ 28 | output_stream_format: Membrane.RawAudio, 29 | transcoding_policy: transcoding_policy 30 | }) 31 | |> child(:wav_output_parser, Membrane.WAV.Serializer) 32 | |> child(:file_sink, %Membrane.File.Sink{location: location}) 33 | 34 | %Ready{actions: [spec: spec]} 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/boombox/internal_bin/webrtc.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.WebRTC do 2 | @moduledoc false 3 | 4 | import Membrane.ChildrenSpec 5 | require Membrane.Pad, as: Pad 6 | alias Boombox.InternalBin.{Ready, State, Wait} 7 | alias Membrane.Bin.CallbackContext 8 | alias Membrane.{H264, RemoteStream, VP8, WebRTC} 9 | 10 | @type output_webrtc_state :: %{negotiated_video_codecs: [:vp8 | :h264] | nil} 11 | @type webrtc_sink_new_tracks :: [%{id: term, kind: :audio | :video}] 12 | 13 | defguardp is_webrtc(endpoint) when is_tuple(endpoint) and elem(endpoint, 0) == :webrtc 14 | defguardp has_webrtc_input(state) when is_webrtc(state.input) 15 | defguardp has_webrtc_output(state) when is_webrtc(state.output) 16 | 17 | @spec create_input(Boombox.webrtc_signaling(), Boombox.output(), CallbackContext.t(), State.t()) :: 18 | Wait.t() 19 | def create_input(signaling, output, ctx, state) do 20 | signaling = resolve_signaling(signaling, :input, ctx.utility_supervisor) 21 | 22 | keyframe_interval = 23 | case output do 24 | {:webrtc, _signaling} -> nil 25 | _other -> Membrane.Time.seconds(2) 26 | end 27 | 28 | {preferred_video_codec, allowed_video_codecs} = 29 | resolve_input_webrtc_codec_options(ctx, state) 30 | 31 | spec = 32 | child(:webrtc_input, %WebRTC.Source{ 33 | signaling: signaling, 34 | preferred_video_codec: preferred_video_codec, 35 | allowed_video_codecs: allowed_video_codecs, 36 | keyframe_interval: keyframe_interval 37 | }) 38 | 39 | %Wait{actions: [spec: spec]} 40 | end 41 | 42 | @spec handle_input_tracks(WebRTC.Source.new_tracks()) :: Ready.t() 43 | def handle_input_tracks(tracks) do 44 | track_builders = 45 | Map.new(tracks, fn 46 | %{kind: :audio, id: id} -> 47 | spec = 48 | get_child(:webrtc_input) 49 | |> via_out(Pad.ref(:output, id)) 50 | 51 | {:audio, spec} 52 | 53 | %{kind: :video, id: id} -> 54 | spec = 55 | get_child(:webrtc_input) 56 | |> via_out(Pad.ref(:output, id)) 57 | 58 | {:video, spec} 59 | end) 60 | 61 | %Ready{track_builders: track_builders} 62 | end 63 | 64 | @spec create_output(Boombox.webrtc_signaling(), CallbackContext.t(), State.t()) :: 65 | {Ready.t() | Wait.t(), State.t()} 66 | def create_output(signaling, ctx, state) do 67 | signaling = resolve_signaling(signaling, :output, ctx.utility_supervisor) 68 | startup_tracks = if has_webrtc_input(state), do: [:audio, :video], else: [] 69 | 70 | spec = 71 | child(:webrtc_output, %WebRTC.Sink{ 72 | signaling: signaling, 73 | tracks: startup_tracks, 74 | video_codec: [:vp8, :h264] 75 | }) 76 | 77 | state = %{state | output_webrtc_state: %{negotiated_video_codecs: nil}} 78 | 79 | {status, state} = 80 | if has_webrtc_input(state) do 81 | # let's spawn websocket server for webrtc source before the source starts 82 | {:webrtc, input_signaling} = state.input 83 | signaling = resolve_signaling(input_signaling, :input, ctx.utility_supervisor) 84 | state = %{state | input: {:webrtc, signaling}} 85 | 86 | {%Wait{actions: [spec: spec]}, state} 87 | else 88 | {%Ready{actions: [spec: spec]}, state} 89 | end 90 | 91 | {status, state} 92 | end 93 | 94 | @spec handle_output_video_codecs_negotiated([:h264 | :vp8], State.t()) :: 95 | {Ready.t() | Wait.t(), State.t()} 96 | def handle_output_video_codecs_negotiated(codecs, state) do 97 | state = put_in(state.output_webrtc_state.negotiated_video_codecs, codecs) 98 | status = if has_webrtc_input(state), do: %Ready{}, else: %Wait{} 99 | {status, state} 100 | end 101 | 102 | @spec link_output( 103 | [Boombox.transcoding_policy_opt()], 104 | Boombox.InternalBin.track_builders(), 105 | Membrane.ChildrenSpec.t(), 106 | webrtc_sink_new_tracks(), 107 | State.t() 108 | ) :: Ready.t() | Wait.t() 109 | def link_output(opts, track_builders, spec_builder, tracks, state) do 110 | if has_webrtc_input(state) do 111 | do_link_output(opts, track_builders, spec_builder, tracks, state) 112 | else 113 | tracks = Bunch.KVEnum.keys(track_builders) 114 | %Wait{actions: [notify_child: {:webrtc_output, {:add_tracks, tracks}}]} 115 | end 116 | end 117 | 118 | @spec handle_output_tracks_negotiated( 119 | [Boombox.transcoding_policy_opt()], 120 | Boombox.InternalBin.track_builders(), 121 | Membrane.ChildrenSpec.t(), 122 | webrtc_sink_new_tracks(), 123 | State.t() 124 | ) :: Ready.t() | no_return() 125 | def handle_output_tracks_negotiated(opts, track_builders, spec_builder, tracks, state) do 126 | if has_webrtc_input(state) do 127 | raise """ 128 | Currently ICE restart is not supported in Boombox instances having WebRTC input and output. 129 | """ 130 | end 131 | 132 | do_link_output(opts, track_builders, spec_builder, tracks, state) 133 | end 134 | 135 | defp do_link_output(opts, track_builders, spec_builder, tracks, state) do 136 | transcoding_policy = opts |> Keyword.get(:transcoding_policy, :if_needed) 137 | tracks = Map.new(tracks, &{&1.kind, &1.id}) 138 | 139 | spec = [ 140 | spec_builder, 141 | Enum.map(track_builders, fn 142 | {:audio, builder} -> 143 | builder 144 | |> child(:mp4_audio_transcoder, %Membrane.Transcoder{ 145 | output_stream_format: Membrane.Opus, 146 | transcoding_policy: transcoding_policy 147 | }) 148 | |> child(:webrtc_out_audio_realtimer, Membrane.Realtimer) 149 | |> via_in(Pad.ref(:input, tracks.audio), options: [kind: :audio]) 150 | |> get_child(:webrtc_output) 151 | 152 | {:video, builder} -> 153 | negotiated_codecs = state.output_webrtc_state.negotiated_video_codecs 154 | 155 | builder 156 | |> child(:webrtc_out_video_realtimer, Membrane.Realtimer) 157 | |> child(:webrtc_video_transcoder, %Membrane.Transcoder{ 158 | output_stream_format: fn input_format -> 159 | resolve_output_video_stream_format( 160 | input_format, 161 | :vp8 in negotiated_codecs, 162 | :h264 in negotiated_codecs, 163 | transcoding_policy 164 | ) 165 | end, 166 | transcoding_policy: transcoding_policy 167 | }) 168 | |> via_in(Pad.ref(:input, tracks.video), options: [kind: :video]) 169 | |> get_child(:webrtc_output) 170 | end) 171 | ] 172 | 173 | %Ready{actions: [spec: spec], eos_info: Map.values(tracks)} 174 | end 175 | 176 | defp resolve_output_video_stream_format( 177 | input_stream_format, 178 | vp8_negotiated?, 179 | h264_negotiated?, 180 | transcoding_policy 181 | ) 182 | when transcoding_policy in [:if_needed, :never] do 183 | case input_stream_format do 184 | %H264{} = h264 when h264_negotiated? -> 185 | %H264{h264 | alignment: :nalu, stream_structure: :annexb} 186 | 187 | %VP8{} = vp8 when vp8_negotiated? -> 188 | vp8 189 | 190 | %RemoteStream{content_format: VP8, type: :packetized} = remote_stream 191 | when vp8_negotiated? -> 192 | remote_stream 193 | 194 | _format when h264_negotiated? -> 195 | %H264{alignment: :nalu, stream_structure: :annexb} 196 | 197 | _format when vp8_negotiated? -> 198 | VP8 199 | end 200 | end 201 | 202 | defp resolve_output_video_stream_format( 203 | _input_stream_format, 204 | vp8_negotiated?, 205 | h264_negotiated?, 206 | :always = _transcoding_policy 207 | ) do 208 | # if we have to perform transcoding one way or another, we always choose H264 if it is possilbe, 209 | # because H264 Encoder comsumes less CPU than VP8 Encoder 210 | cond do 211 | h264_negotiated? -> %H264{alignment: :nalu, stream_structure: :annexb} 212 | vp8_negotiated? -> VP8 213 | end 214 | end 215 | 216 | defp resolve_signaling( 217 | %WebRTC.Signaling{} = signaling, 218 | _direction, 219 | _utility_supervisor 220 | ) do 221 | signaling 222 | end 223 | 224 | defp resolve_signaling({:whip, uri, opts}, :input, utility_supervisor) do 225 | uri = URI.new!(uri) 226 | {:ok, ip} = :inet.getaddr(~c"#{uri.host}", :inet) 227 | setup_whip_server([ip: ip, port: uri.port] ++ opts, utility_supervisor) 228 | end 229 | 230 | defp resolve_signaling({:whip, uri, opts}, :output, utility_supervisor) do 231 | signaling = WebRTC.Signaling.new() 232 | 233 | Membrane.UtilitySupervisor.start_link_child( 234 | utility_supervisor, 235 | {WebRTC.WhipClient, [signaling: signaling, uri: uri] ++ opts} 236 | ) 237 | 238 | signaling 239 | end 240 | 241 | defp resolve_signaling(uri, _direction, utility_supervisor) when is_binary(uri) do 242 | uri = URI.new!(uri) 243 | {:ok, ip} = :inet.getaddr(~c"#{uri.host}", :inet) 244 | opts = [ip: ip, port: uri.port] 245 | 246 | WebRTC.SimpleWebSocketServer.start_link_supervised(utility_supervisor, opts) 247 | end 248 | 249 | defp setup_whip_server(opts, utility_supervisor) do 250 | signaling = WebRTC.Signaling.new() 251 | clients_cnt = :atomics.new(1, []) 252 | {valid_token, opts} = Keyword.pop(opts, :token) 253 | 254 | handle_new_client = fn token -> 255 | cond do 256 | valid_token not in [nil, token] -> {:error, :invalid_token} 257 | :atomics.add_get(clients_cnt, 1, 1) > 1 -> {:error, :already_connected} 258 | true -> {:ok, signaling} 259 | end 260 | end 261 | 262 | Membrane.UtilitySupervisor.start_child(utility_supervisor, { 263 | WebRTC.WhipServer, 264 | [handle_new_client: handle_new_client] ++ opts 265 | }) 266 | 267 | signaling 268 | end 269 | 270 | defp resolve_input_webrtc_codec_options(_ctx, state) when has_webrtc_output(state) do 271 | case state.output_webrtc_state do 272 | %{negotiated_video_codecs: []} -> 273 | # preferred_video_codec will be ignored 274 | {:vp8, []} 275 | 276 | %{negotiated_video_codecs: [codec]} -> 277 | {codec, [:h264, :vp8]} 278 | 279 | %{negotiated_video_codecs: [_codec_1, _codec_2] = codecs} -> 280 | {:vp8, codecs} 281 | end 282 | end 283 | 284 | # case when Boombox.Bin has pads linked before handle_playing 285 | defp resolve_input_webrtc_codec_options(ctx, state) 286 | when state.output == :membrane_pad and map_size(ctx.pads) > 0 do 287 | output_video_pad_options = 288 | ctx.pads 289 | |> Enum.find_value(fn 290 | {Pad.ref(:output, _id), %{options: %{kind: :video} = options}} -> options 291 | _pad_entry -> false 292 | end) 293 | 294 | with %{codec: codec} <- output_video_pad_options do 295 | preferred_codec = 296 | case codec do 297 | [first_codec | _rest] -> first_codec 298 | atom when is_atom(atom) -> atom 299 | end 300 | |> case do 301 | H264 -> :h264 302 | VP8 -> :vp8 303 | _other -> :h264 304 | end 305 | 306 | {preferred_codec, [:h264, :vp8]} 307 | else 308 | nil -> 309 | # there is no output video pad so video codec shouldn't be negotiated so 310 | # value returned from this function will be ignored 311 | {:h264, []} 312 | end 313 | end 314 | 315 | # case when Boombox.Bin returns a :new_tracks notification 316 | defp resolve_input_webrtc_codec_options(ctx, state) 317 | when state.output == :membrane_pad and map_size(ctx.pads) == 0 do 318 | {:h264, [:h264, :vp8]} 319 | end 320 | 321 | defp resolve_input_webrtc_codec_options(_ctx, _state) do 322 | {:h264, [:h264, :vp8]} 323 | end 324 | end 325 | -------------------------------------------------------------------------------- /lib/boombox/packet.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.Packet do 2 | @moduledoc """ 3 | Data structure emitted and accepted by `Boombox.run/1` 4 | when using `:stream` input or output. 5 | """ 6 | 7 | @typedoc @moduledoc 8 | @type t :: %__MODULE__{ 9 | payload: payload(), 10 | pts: Membrane.Time.t(), 11 | kind: :audio | :video, 12 | format: format() 13 | } 14 | 15 | @type payload :: Vix.Vips.Image.t() | binary() 16 | @type format :: 17 | %{} 18 | | %{ 19 | audio_format: Membrane.RawAudio.SampleFormat.t(), 20 | audio_rate: pos_integer(), 21 | audio_channels: pos_integer() 22 | } 23 | 24 | @enforce_keys [:payload, :pts, :kind] 25 | defstruct @enforce_keys ++ [format: %{}] 26 | 27 | @spec update_payload(t(), (payload() -> payload())) :: t() 28 | def update_payload(packet, fun) do 29 | %__MODULE__{packet | payload: fun.(packet.payload)} 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/boombox/pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.Pipeline do 2 | @moduledoc false 3 | use Membrane.Pipeline 4 | 5 | @impl true 6 | def handle_init(_ctx, opts) do 7 | spec = 8 | child(:boombox, %Boombox.InternalBin{ 9 | input: opts.input, 10 | output: opts.output 11 | }) 12 | 13 | {[spec: spec], %{parent: opts.parent}} 14 | end 15 | 16 | @impl true 17 | def handle_child_notification(:external_resource_ready, _element, _context, state) do 18 | send(state.parent, :external_resource_ready) 19 | {[], state} 20 | end 21 | 22 | @impl true 23 | def handle_child_notification(:processing_finished, :boombox, _ctx, state) do 24 | {[terminate: :normal], state} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/boombox/utils/burrito_app.ex: -------------------------------------------------------------------------------- 1 | if Code.ensure_loaded?(Burrito) do 2 | defmodule Boombox.Utils.BurritoApp do 3 | @moduledoc false 4 | 5 | use Application 6 | 7 | @dialyzer {:no_return, {:start, 2}} 8 | @impl true 9 | def start(_type, _args) do 10 | Boombox.run_cli(Burrito.Util.Args.argv()) 11 | System.halt() 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/boombox/utils/cli.ex: -------------------------------------------------------------------------------- 1 | defmodule Boombox.Utils.CLI do 2 | @moduledoc false 3 | 4 | # first element of the tuple is switch type, and the second is the elixir type 5 | @arg_types [ 6 | mp4: {:string, :string}, 7 | webrtc: {:string, :string}, 8 | rtmp: {:string, :string}, 9 | hls: {:string, :string}, 10 | transport: {:string, :string}, 11 | rtp: {:boolean, nil}, 12 | port: {:integer, :integer}, 13 | address: {:string, :string}, 14 | target: {:string, :string}, 15 | video_encoding: {:string, :atom}, 16 | video_payload_type: {:integer, :integer}, 17 | video_clock_rate: {:integer, :integer}, 18 | audio_encoding: {:string, :atom}, 19 | audio_payload_type: {:integer, :integer}, 20 | audio_clock_rate: {:integer, :integer}, 21 | aac_bitrate_mode: {:string, :atom}, 22 | audio_specific_config: {:string, :binary}, 23 | pps: {:string, :binary}, 24 | sps: {:string, :binary}, 25 | vps: {:string, :binary}, 26 | whip: {:string, :string}, 27 | token: {:string, :string}, 28 | transcoding_policy: {:string, :atom} 29 | ] 30 | 31 | @spec parse_argv([String.t()]) :: 32 | {:args, input: Boombox.input(), output: Boombox.output()} | {:script, String.t()} 33 | def parse_argv(argv) do 34 | OptionParser.parse(argv, strict: [script: :string], aliases: [s: :script, S: :script]) 35 | |> case do 36 | {[], _argv, _switches} -> 37 | {:args, parse_args(argv)} 38 | 39 | result -> 40 | [script: script] = handle_option_parser_result(result) 41 | {:script, script} 42 | end 43 | end 44 | 45 | defp parse_args(argv) do 46 | aliases = [i: :input, o: :output] 47 | i_type = [get_switch_type(argv, :input, aliases), :keep] 48 | o_type = [get_switch_type(argv, :output, aliases), :keep] 49 | 50 | switches = 51 | [input: i_type, output: o_type] ++ 52 | Keyword.new(@arg_types, fn {key, {switch_type, _elixir_type}} -> 53 | {key, [switch_type, :keep]} 54 | end) 55 | 56 | {input, output} = 57 | OptionParser.parse(argv, strict: switches, aliases: aliases) 58 | |> handle_option_parser_result() 59 | |> case do 60 | [input: _value] ++ _rest = parsed -> 61 | Enum.split_while(parsed, fn {k, _v} -> k != :output end) 62 | 63 | [output: _value] ++ _rest = parsed -> 64 | Enum.split_while(parsed, fn {k, _v} -> k != :input end) 65 | 66 | _other -> 67 | cli_exit_error() 68 | end 69 | 70 | [resolve_endpoint(input), resolve_endpoint(output)] 71 | end 72 | 73 | @spec get_switch_type([String.t()], atom(), Keyword.t()) :: :boolean | :string 74 | defp get_switch_type(argv, option, aliases) do 75 | with [] <- OptionParser.parse(argv, strict: [{option, :string}], aliases: aliases) |> elem(0), 76 | [] <- OptionParser.parse(argv, strict: [{option, :boolean}], aliases: aliases) |> elem(0) do 77 | cli_exit_error("#{option} not provided") 78 | else 79 | [{^option, true}] -> :boolean 80 | [{^option, string}] when is_binary(string) -> :string 81 | end 82 | end 83 | 84 | @spec resolve_endpoint(Keyword.t()) :: {:input, Boombox.input()} | {:output, Boombox.output()} 85 | defp resolve_endpoint(parsed) do 86 | case parsed do 87 | [{direction, true}, {endpoint, true}] -> 88 | {direction, endpoint} 89 | 90 | [{direction, true}, {endpoint, value}] -> 91 | {direction, {endpoint, value}} 92 | 93 | [{direction, true}, {endpoint, true} | opts] -> 94 | {direction, {endpoint, translate_opts(opts)}} 95 | 96 | [{direction, true}, {endpoint, value} | opts] -> 97 | {direction, {endpoint, value, translate_opts(opts)}} 98 | 99 | [{direction, value}] -> 100 | {direction, value} 101 | 102 | [{direction, value} | opts] -> 103 | {direction, {value, translate_opts(opts)}} 104 | 105 | _other -> 106 | cli_exit_error() 107 | end 108 | end 109 | 110 | @spec handle_option_parser_result({Keyword.t(), [String.t()], Keyword.t()}) :: Keyword.t() 111 | defp handle_option_parser_result(result) do 112 | case result do 113 | {parsed, [], []} -> parsed 114 | {_parsed, _argv, [{s, _v} | _switches]} -> cli_exit_error("unexpected option '#{s}'") 115 | {_parsed, [arg | _argv], []} -> cli_exit_error("unexpected value '#{arg}'") 116 | end 117 | end 118 | 119 | @spec translate_opts(Keyword.t()) :: Keyword.t() 120 | defp translate_opts(opts) do 121 | Keyword.new(opts, fn {opt, value} -> 122 | case @arg_types[opt] do 123 | {:string, :atom} -> {opt, String.to_atom(value)} 124 | {:string, :binary} -> {opt, decode_binary_opt(value)} 125 | {type, type} -> {opt, value} 126 | end 127 | end) 128 | end 129 | 130 | @spec decode_binary_opt(String.t()) :: binary() 131 | defp decode_binary_opt(opt_value) do 132 | case Base.decode16(opt_value, case: :mixed) do 133 | {:ok, value} -> value 134 | :error -> cli_exit_error("bad base16 string '#{inspect(opt_value)}'") 135 | end 136 | end 137 | 138 | @spec cli_exit_error() :: no_return 139 | @spec cli_exit_error(String.t() | nil) :: no_return 140 | defp cli_exit_error(description \\ nil) do 141 | description = if description, do: "Error: #{description}\n\n" 142 | 143 | IO.puts(""" 144 | #{description}\ 145 | Usage: boombox -i [input] -o [output] 146 | 147 | Examples: 148 | 149 | boombox -i rtmp://localhost:5432 -o output/index.m3u8 150 | boombox -i --webrtc ws://localhost:8829 -o file.mp4 151 | """) 152 | 153 | System.halt(1) 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Boombox.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.2.1" 5 | @github_url "https://github.com/membraneframework/boombox" 6 | 7 | def project do 8 | [ 9 | app: :boombox, 10 | version: @version, 11 | elixir: "~> 1.13", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | start_permanent: Mix.env() == :prod, 14 | deps: deps(), 15 | dialyzer: dialyzer(), 16 | releases: releases(), 17 | aliases: aliases(), 18 | 19 | # hex 20 | description: "Boombox", 21 | package: package(), 22 | 23 | # docs 24 | name: "Boombox", 25 | source_url: @github_url, 26 | docs: docs() 27 | ] 28 | end 29 | 30 | def application do 31 | [ 32 | extra_applications: [], 33 | mod: 34 | if burrito?() do 35 | {Boombox.Utils.BurritoApp, []} 36 | else 37 | {Boombox.Application, []} 38 | end 39 | ] 40 | end 41 | 42 | defp burrito?, do: System.get_env("BOOMBOX_BURRITO") != nil 43 | 44 | defp elixirc_paths(:test), do: ["lib", "test/support"] 45 | defp elixirc_paths(_env), do: ["lib"] 46 | 47 | defp deps do 48 | [ 49 | {:membrane_core, "~> 1.2"}, 50 | {:membrane_transcoder_plugin, "~> 0.3.0"}, 51 | {:membrane_webrtc_plugin, "~> 0.25.0"}, 52 | {:membrane_mp4_plugin, "~> 0.35.2"}, 53 | {:membrane_realtimer_plugin, "~> 0.9.0"}, 54 | {:membrane_http_adaptive_stream_plugin, "~> 0.18.5"}, 55 | {:membrane_rtmp_plugin, "~> 0.27.2"}, 56 | {:membrane_rtsp_plugin, "~> 0.6.1"}, 57 | {:membrane_rtp_plugin, "~> 0.30.0"}, 58 | {:membrane_rtp_format, "~> 0.10.0"}, 59 | {:membrane_rtp_aac_plugin, "~> 0.9.0"}, 60 | {:membrane_rtp_h264_plugin, "~> 0.20.0"}, 61 | {:membrane_rtp_opus_plugin, "~> 0.10.0"}, 62 | {:membrane_rtp_h265_plugin, "~> 0.5.2"}, 63 | {:membrane_ffmpeg_swresample_plugin, "~> 0.20.0"}, 64 | {:membrane_hackney_plugin, "~> 0.11.0"}, 65 | {:membrane_ffmpeg_swscale_plugin, "~> 0.16.2"}, 66 | {:membrane_wav_plugin, "~> 0.10.1"}, 67 | {:membrane_ivf_plugin, "~> 0.8.0"}, 68 | {:membrane_ogg_plugin, "~> 0.5.0"}, 69 | {:membrane_stream_plugin, "~> 0.4.0"}, 70 | {:membrane_simple_rtsp_server, "~> 0.1.4", only: :test}, 71 | {:image, "~> 0.54.0"}, 72 | # {:playwright, "~> 1.49.1-alpha.1", only: :test}, 73 | {:playwright, 74 | github: "membraneframework-labs/playwright-elixir", 75 | ref: "5c02249512fa543f5e619a69b7e5c9e046605fe5", 76 | only: :test}, 77 | {:burrito, "~> 1.0", runtime: burrito?(), optional: true}, 78 | {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, 79 | {:dialyxir, ">= 0.0.0", only: :dev, runtime: false}, 80 | {:credo, ">= 0.0.0", only: :dev, runtime: false} 81 | ] 82 | end 83 | 84 | defp dialyzer() do 85 | opts = [ 86 | flags: [:error_handling] 87 | ] 88 | 89 | if System.get_env("CI") == "true" do 90 | # Store PLTs in cacheable directory for CI 91 | [plt_local_path: "priv/plts", plt_core_path: "priv/plts"] ++ opts 92 | else 93 | opts 94 | end 95 | end 96 | 97 | defp package do 98 | [ 99 | maintainers: ["Membrane Team"], 100 | licenses: ["Apache-2.0"], 101 | links: %{ 102 | "GitHub" => @github_url, 103 | "Membrane Framework Homepage" => "https://membrane.stream" 104 | }, 105 | files: ["lib", "mix.exs", "README*", "LICENSE*", ".formatter.exs", "bin/boombox"] 106 | ] 107 | end 108 | 109 | defp aliases do 110 | [docs: [&generate_docs_examples/1, "docs"]] 111 | end 112 | 113 | defp generate_docs_examples(_) do 114 | docs_install_config = "boombox = :boombox" 115 | 116 | modified_livebook = 117 | File.read!("examples.livemd") 118 | |> String.replace( 119 | ~r/# MIX_INSTALL_CONFIG_BEGIN\n(.|\n)*# MIX_INSTALL_CONFIG_END\n/U, 120 | docs_install_config, 121 | global: false 122 | ) 123 | 124 | File.write!("#{Mix.Project.build_path()}/examples.livemd", modified_livebook) 125 | end 126 | 127 | defp docs do 128 | [ 129 | main: "readme", 130 | extras: [ 131 | "README.md", 132 | {"#{Mix.Project.build_path()}/examples.livemd", title: "Examples"}, 133 | {"LICENSE", title: "License"} 134 | ], 135 | formatters: ["html"], 136 | source_ref: "v#{@version}", 137 | nest_modules_by_prefix: [Boombox] 138 | ] 139 | end 140 | 141 | defp releases() do 142 | {burrito_wrap, burrito_config} = 143 | if burrito?() do 144 | {&Burrito.wrap/1, burrito_config()} 145 | else 146 | {& &1, []} 147 | end 148 | 149 | [ 150 | boombox: 151 | [ 152 | steps: [:assemble, &restore_symlinks/1, burrito_wrap] 153 | ] ++ burrito_config, 154 | server: [ 155 | steps: [:assemble, &restore_symlinks/1] 156 | ] 157 | ] 158 | end 159 | 160 | defp burrito_config() do 161 | current_os = 162 | case :os.type() do 163 | {:win32, _} -> :windows 164 | {:unix, :darwin} -> :darwin 165 | {:unix, :linux} -> :linux 166 | end 167 | 168 | arch_string = 169 | :erlang.system_info(:system_architecture) 170 | |> to_string() 171 | |> String.downcase() 172 | |> String.split("-") 173 | |> List.first() 174 | 175 | current_cpu = 176 | case arch_string do 177 | "x86_64" -> :x86_64 178 | "arm64" -> :aarch64 179 | "aarch64" -> :aarch64 180 | _ -> :unknown 181 | end 182 | 183 | [ 184 | burrito: [ 185 | targets: [ 186 | current: [os: current_os, cpu: current_cpu] 187 | ] 188 | ] 189 | ] 190 | end 191 | 192 | # mix release doesn't preserve symlinks, but replaces 193 | # them with whatever they point to, while 194 | # bundlex uses symlinks to provide precompiled deps. 195 | # That makes the release size enormous, so this workaroud 196 | # recreates the symlinks by replacing the copied data 197 | # with new symlinks pointing to bundlex's 198 | # priv/shared/precompiled directory 199 | defp restore_symlinks(release) do 200 | base_dir = "#{__DIR__}/_build/dev/rel/boombox/lib" 201 | 202 | shared = 203 | Path.wildcard("#{base_dir}/bundlex*/priv/shared/precompiled/*") 204 | |> Enum.map(&Path.relative_to(&1, base_dir)) 205 | |> Map.new(&{Path.basename(&1), &1}) 206 | 207 | Path.wildcard("#{base_dir}/*/priv/bundlex/*/*") 208 | |> Enum.each(fn path -> 209 | name = Path.basename(path) 210 | 211 | case shared[name] do 212 | nil -> 213 | :ok 214 | 215 | shared_dir -> 216 | File.rm_rf!(path) 217 | depth = path |> Path.relative_to(base_dir) |> Path.split() |> length() 218 | ln = String.duplicate("../", depth - 1) <> shared_dir 219 | dbg(path) 220 | dbg(ln) 221 | File.ln_s!(ln, path) 222 | end 223 | end) 224 | 225 | release 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /test/boombox_bin_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Boombox.BinTest do 2 | use ExUnit.Case, async: false 3 | 4 | import Membrane.ChildrenSpec 5 | import Membrane.Testing.Assertions 6 | 7 | require Logger 8 | 9 | alias Membrane.{ 10 | AAC, 11 | H264, 12 | Opus, 13 | RawAudio, 14 | RawVideo, 15 | RemoteStream, 16 | Testing, 17 | Transcoder, 18 | VP8 19 | } 20 | 21 | alias Support.Compare 22 | 23 | @bbb_mp4 "test/fixtures/bun10s.mp4" 24 | 25 | @video_formats_for_sink_bin Macro.escape([ 26 | {H264, :avc3, :au}, 27 | {H264, :annexb, :nalu}, 28 | RawVideo, 29 | VP8, 30 | nil 31 | ]) 32 | 33 | @audio_formats_for_sink_bin Macro.escape([AAC, Opus, RawAudio, nil]) 34 | 35 | defmodule Format do 36 | @spec to_string(any()) :: String.t() 37 | def to_string(nil), do: "absent" 38 | def to_string(format), do: inspect(format) 39 | end 40 | 41 | for video_format <- @video_formats_for_sink_bin, audio_format <- @audio_formats_for_sink_bin do 42 | if video_format != nil or audio_format != nil do 43 | @tag :tmp_dir 44 | test "Boombox.Bin with input pad when video is #{Format.to_string(video_format)} and audio is #{Format.to_string(audio_format)}", 45 | %{tmp_dir: tmp_dir} do 46 | video_format = 47 | with {H264, stream_structure, alignment} <- unquote(video_format) do 48 | %H264{stream_structure: stream_structure, alignment: alignment} 49 | end 50 | 51 | do_test_sink_bin(video_format, unquote(audio_format), tmp_dir) 52 | end 53 | end 54 | end 55 | 56 | defp do_test_sink_bin(video_format, audio_format, tmp_dir) do 57 | out_file = Path.join(tmp_dir, "out.mp4") 58 | 59 | spec = [ 60 | child(:boombox, %Boombox.Bin{output: out_file}), 61 | spec_branch(:video, video_format), 62 | spec_branch(:audio, audio_format) 63 | ] 64 | 65 | pipeline = Testing.Pipeline.start_link_supervised!(spec: spec) 66 | assert_pipeline_notified(pipeline, :boombox, :processing_finished, 5_000) 67 | Testing.Pipeline.terminate(pipeline) 68 | 69 | tracks_number = [video_format, audio_format] |> Enum.count(&(&1 != nil)) 70 | 71 | if video_format != nil do 72 | Compare.compare(out_file, "test/fixtures/ref_bun10s_aac.mp4", 73 | kinds: [:video], 74 | expected_subject_tracks_number: tracks_number 75 | ) 76 | end 77 | 78 | if audio_format != nil do 79 | Compare.compare(out_file, audio_fixture(audio_format), 80 | kinds: [:audio], 81 | audio_error_bounadry: 40_000, 82 | expected_subject_tracks_number: tracks_number 83 | ) 84 | end 85 | end 86 | 87 | @video_formats_for_source_bin Macro.escape([H264, VP8, RawVideo]) 88 | @audio_formats_for_source_bin Macro.escape([AAC, Opus, RawAudio]) 89 | 90 | for video_codec <- @video_formats_for_source_bin, 91 | audio_codec <- @audio_formats_for_source_bin do 92 | test "Boombox.Bin with output pad when video is #{Format.to_string(video_codec)} and audio is #{Format.to_string(audio_codec)}" do 93 | do_test_source_bin( 94 | unquote(video_codec), 95 | unquote(audio_codec) 96 | ) 97 | end 98 | end 99 | 100 | defp do_test_source_bin(video_codec, audio_codec) do 101 | spec = [ 102 | child(:boombox, %Boombox.Bin{input: @bbb_mp4}) 103 | |> via_out(:output, 104 | options: [ 105 | kind: :audio, 106 | codec: audio_codec 107 | ] 108 | ) 109 | |> child(:audio_sink, Testing.Sink), 110 | get_child(:boombox) 111 | |> via_out(:output, 112 | options: [ 113 | kind: :video, 114 | codec: video_codec 115 | ] 116 | ) 117 | |> child(:video_sink, Testing.Sink) 118 | ] 119 | 120 | pipeline = Testing.Pipeline.start_link_supervised!(spec: spec) 121 | 122 | assert_sink_stream_format(pipeline, :audio_sink, audio_format) 123 | assert audio_format.__struct__ == audio_codec 124 | 125 | assert_sink_stream_format(pipeline, :video_sink, video_format) 126 | assert video_format.__struct__ == video_codec 127 | 128 | Testing.Pipeline.terminate(pipeline) 129 | end 130 | 131 | defp audio_fixture(Opus), do: "test/fixtures/ref_bun10s_opus_aac.mp4" 132 | defp audio_fixture(_format), do: "test/fixtures/ref_bun10s_aac.mp4" 133 | 134 | defp spec_branch(_kind, nil), do: [] 135 | 136 | defp spec_branch(kind, transcoding_format) do 137 | opposite_kind = if kind == :audio, do: :video, else: :audio 138 | 139 | [ 140 | child(%Membrane.File.Source{location: @bbb_mp4}) 141 | |> child({:mp4_demuxer, kind}, Membrane.MP4.Demuxer.ISOM) 142 | |> via_out(:output, options: [kind: kind]) 143 | |> child(%Transcoder{output_stream_format: transcoding_format}) 144 | |> via_in(:input, options: [kind: kind]) 145 | |> get_child(:boombox), 146 | get_child({:mp4_demuxer, kind}) 147 | |> via_out(:output, options: [kind: opposite_kind]) 148 | |> child(Membrane.Debug.Sink) 149 | ] 150 | end 151 | 152 | describe "Boombox.Bin raises when" do 153 | test "it has input pad linked and `:input` option set at the same time" do 154 | spec = 155 | child(%Testing.Source{ 156 | stream_format: %RemoteStream{content_format: Opus, type: :packetized} 157 | }) 158 | |> via_in(:input, options: [kind: :audio]) 159 | |> child(%Boombox.Bin{ 160 | input: {:webrtc, "ws://localhost:5432"}, 161 | output: {:webrtc, "ws://localhost:5433"} 162 | }) 163 | 164 | assert {:error, {%Membrane.ParentError{}, _stacktrace}} = 165 | Testing.Pipeline.start(spec: spec) 166 | end 167 | 168 | test "its input and output pads are linked at the same time" do 169 | spec = 170 | child(%Testing.Source{ 171 | stream_format: %RemoteStream{content_format: Opus, type: :packetized} 172 | }) 173 | |> via_in(:input, options: [kind: :audio]) 174 | |> child(Boombox.Bin) 175 | |> via_out(:output, options: [kind: :audio]) 176 | |> child(Testing.Sink) 177 | 178 | assert {:error, {%Membrane.ParentError{}, _stacktrace}} = 179 | Testing.Pipeline.start(spec: spec) 180 | end 181 | 182 | @tag :tmp_dir 183 | test "pad is linked after `handle_playing/2`", %{tmp_dir: tmp_dir} do 184 | generator = 185 | fn _state, _demand -> 186 | receive do 187 | :will_not_match -> :ok 188 | after 189 | 100 -> :ok 190 | end 191 | 192 | {[redemand: :output], nil} 193 | end 194 | 195 | spec = 196 | child(%Testing.Source{ 197 | stream_format: %RemoteStream{content_format: Opus, type: :packetized}, 198 | output: {nil, generator} 199 | }) 200 | |> via_in(:input, options: [kind: :audio]) 201 | |> child(:boombox, %Boombox.Bin{ 202 | output: Path.join(tmp_dir, "file.mp4") 203 | }) 204 | 205 | {:ok, supervisor, pipeline} = Testing.Pipeline.start(spec: spec) 206 | ref = Process.monitor(supervisor) 207 | 208 | Process.sleep(500) 209 | assert Process.alive?(supervisor) 210 | 211 | new_spec = 212 | child(%Testing.Source{ 213 | stream_format: %RemoteStream{content_format: VP8, type: :packetized}, 214 | output: {nil, generator} 215 | }) 216 | |> via_in(:input, options: [kind: :video]) 217 | |> get_child(:boombox) 218 | 219 | Testing.Pipeline.execute_actions(pipeline, spec: new_spec) 220 | assert_receive {:DOWN, ^ref, :process, _supervisor, _reason} 221 | end 222 | end 223 | 224 | describe "Boombox.Bin when it returns :new_tracks notification" do 225 | @tag :tmp_dir 226 | test "(mp4 -> mp4)", %{tmp_dir: tmp_dir} do 227 | spec = child(:source_boombox, %Boombox.Bin{input: @bbb_mp4}) 228 | pipeline = Testing.Pipeline.start_link_supervised!(spec: spec) 229 | 230 | assert_pipeline_notified(pipeline, :source_boombox, {:new_tracks, tracks}) 231 | assert MapSet.new(tracks) == MapSet.new([:video, :audio]) 232 | 233 | out_file = Path.join(tmp_dir, "out.mp4") 234 | 235 | spec = 236 | [child(:sink_boombox, %Boombox.Bin{output: out_file})] ++ 237 | for kind <- [:video, :audio] do 238 | get_child(:source_boombox) 239 | |> via_out(:output, options: [kind: kind]) 240 | |> via_in(:input, options: [kind: kind]) 241 | |> get_child(:sink_boombox) 242 | end 243 | 244 | Testing.Pipeline.execute_actions(pipeline, spec: spec) 245 | 246 | assert_pipeline_notified(pipeline, :sink_boombox, :processing_finished, 5_000) 247 | 248 | Testing.Pipeline.terminate(pipeline) 249 | 250 | assert File.exists?(out_file) 251 | Compare.compare(out_file, "test/fixtures/ref_bun10s_aac.mp4", kinds: [:video, :audio]) 252 | end 253 | 254 | @tag :tmp_dir 255 | test "(mp4 -> webrtc -> mp4)", %{tmp_dir: tmp_dir} do 256 | input_mp4_spec = child(:mp4_source_boombox, %Boombox.Bin{input: @bbb_mp4}) 257 | source_pipeline = Testing.Pipeline.start_link_supervised!(spec: input_mp4_spec) 258 | 259 | assert_pipeline_notified(source_pipeline, :mp4_source_boombox, {:new_tracks, tracks}) 260 | assert MapSet.new(tracks) == MapSet.new([:video, :audio]) 261 | 262 | webrtc_signaling = Membrane.WebRTC.Signaling.new() 263 | 264 | output_webrtc_spec = 265 | [child(:webrtc_sink_boombox, %Boombox.Bin{output: {:webrtc, webrtc_signaling}})] ++ 266 | for kind <- tracks do 267 | get_child(:mp4_source_boombox) 268 | |> via_out(:output, options: [kind: kind]) 269 | |> via_in(:input, options: [kind: kind]) 270 | |> get_child(:webrtc_sink_boombox) 271 | end 272 | 273 | Testing.Pipeline.execute_actions(source_pipeline, spec: output_webrtc_spec) 274 | 275 | input_webrtc_spec = 276 | child(:webrtc_source_boombox, %Boombox.Bin{ 277 | input: {:webrtc, webrtc_signaling} 278 | }) 279 | 280 | sink_pipeline = Testing.Pipeline.start_link_supervised!(spec: input_webrtc_spec) 281 | 282 | assert_pipeline_notified(sink_pipeline, :webrtc_source_boombox, {:new_tracks, tracks}) 283 | assert MapSet.new(tracks) == MapSet.new([:video, :audio]) 284 | 285 | out_file = Path.join(tmp_dir, "out.mp4") 286 | 287 | output_mp4_spec = 288 | [child(:mp4_sink_boombox, %Boombox.Bin{output: out_file})] ++ 289 | for kind <- tracks do 290 | get_child(:webrtc_source_boombox) 291 | |> via_out(:output, options: [kind: kind]) 292 | |> via_in(:input, options: [kind: kind]) 293 | |> get_child(:mp4_sink_boombox) 294 | end 295 | 296 | Testing.Pipeline.execute_actions(sink_pipeline, spec: output_mp4_spec) 297 | 298 | assert_pipeline_notified( 299 | source_pipeline, 300 | :webrtc_sink_boombox, 301 | :processing_finished, 302 | 12_000 303 | ) 304 | 305 | Testing.Pipeline.terminate(source_pipeline) 306 | 307 | assert_pipeline_notified(sink_pipeline, :mp4_sink_boombox, :processing_finished) 308 | Testing.Pipeline.terminate(sink_pipeline) 309 | 310 | assert File.exists?(out_file) 311 | Compare.compare(out_file, "test/fixtures/ref_bun10s_aac.mp4", kinds: [:video, :audio]) 312 | end 313 | end 314 | end 315 | -------------------------------------------------------------------------------- /test/boombox_storage_endpoints_test.exs: -------------------------------------------------------------------------------- 1 | defmodule BoomboxStorageEndpointsTest do 2 | use ExUnit.Case, async: true 3 | import Support.Async 4 | alias Support.Compare 5 | 6 | @just_audio_inputs ["bun10s.aac", "bun10s.ogg", "bun10s.mp3", "bun10s.wav"] 7 | @just_audio_outputs [ 8 | {:aac, [:audio]}, 9 | {:ogg, [:audio]}, 10 | {:mp3, [:audio]}, 11 | {:wav, [:audio]} 12 | ] 13 | @just_audio_cases for input <- @just_audio_inputs, 14 | output <- @just_audio_outputs, 15 | do: {input, output} 16 | 17 | @just_video_inputs ["bun10s.ivf", "bun10s.h264", "bun10s.h265"] 18 | @just_video_outputs [{:ivf, [:video]}, {:h264, [:video]}] 19 | @just_video_cases for input <- @just_video_inputs, 20 | output <- @just_video_outputs, 21 | do: {input, output} 22 | @av_inputs ["bun10s.mp4"] 23 | @av_outputs [{:mp4, [:audio, :video]}] 24 | @av_cases for input <- @av_inputs, 25 | output <- @av_outputs, 26 | do: {input, output} 27 | 28 | @test_cases @just_audio_cases ++ @just_video_cases ++ @av_cases 29 | 30 | @moduletag :tmp_dir 31 | 32 | Enum.each(@test_cases, fn {input_path, {output_type, kinds}} -> 33 | async_test "#{inspect(input_path)} file -> #{inspect(output_type)} file", %{tmp_dir: tmp} do 34 | fixtures_dir = "test/fixtures/storage_endpoints/" 35 | ref_file = Path.join(fixtures_dir, "bun10s.mp4") 36 | output_path = Path.join(tmp, "output") 37 | 38 | Boombox.run( 39 | input: Path.join(fixtures_dir, unquote(input_path)), 40 | output: {unquote(output_type), output_path} 41 | ) 42 | 43 | output_mp4_path = Path.join(tmp, "output.mp4") 44 | 45 | Boombox.run( 46 | input: {unquote(output_type), output_path}, 47 | output: output_mp4_path 48 | ) 49 | 50 | Compare.compare(output_mp4_path, ref_file, kinds: unquote(kinds)) 51 | end 52 | end) 53 | end 54 | -------------------------------------------------------------------------------- /test/boombox_utils_cli_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Boombox.Utils.CLITest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Boombox.Utils.CLI 5 | 6 | test "parse args" do 7 | assert {:args, input: "rtmp://localhost:5432", output: "output/index.m3u8"} == 8 | CLI.parse_argv(~w(-i rtmp://localhost:5432 -o output/index.m3u8)) 9 | 10 | assert {:args, input: "file.mp4", output: {:webrtc, "ws://localhost:1234"}} == 11 | CLI.parse_argv(~w(-i file.mp4 -o --webrtc ws://localhost:1234)) 12 | 13 | assert {:script, "path/to/script.exs"} = CLI.parse_argv(~w(-S path/to/script.exs)) 14 | 15 | assert {:args, 16 | input: 17 | {:rtp, 18 | port: 5001, 19 | audio_encoding: :AAC, 20 | audio_specific_config: <<161, 63>>, 21 | aac_bitrate_mode: :hbr}, 22 | output: "index.m3u8"} == 23 | CLI.parse_argv( 24 | ~w(-i --rtp --port 5001 --audio-encoding AAC --audio-specific-config a13f --aac-bitrate-mode hbr -o index.m3u8) 25 | ) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/browser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Boombox.BrowserTest do 2 | use ExUnit.Case, async: false 3 | 4 | # Tests from this module are currently switched off on the CI because 5 | # they raise some errors there, that doesn't occur locally (probably 6 | # because of the problems with granting permissions for camera and 7 | # microphone access) 8 | 9 | require Logger 10 | 11 | @port 1235 12 | 13 | @moduletag :browser 14 | 15 | setup_all do 16 | browser_launch_opts = %{ 17 | args: [ 18 | "--use-fake-device-for-media-stream", 19 | "--use-fake-ui-for-media-stream" 20 | ], 21 | headless: true 22 | } 23 | 24 | Application.put_env(:playwright, LaunchOptions, browser_launch_opts) 25 | {:ok, _apps} = Application.ensure_all_started(:playwright) 26 | 27 | :inets.stop() 28 | :ok = :inets.start() 29 | 30 | {:ok, _server} = 31 | :inets.start(:httpd, 32 | bind_address: ~c"localhost", 33 | port: @port, 34 | document_root: ~c"boombox_examples_data", 35 | server_name: ~c"assets_server", 36 | server_root: ~c"/tmp", 37 | erl_script_nocache: true 38 | ) 39 | 40 | [] 41 | end 42 | 43 | setup do 44 | {_pid, browser} = Playwright.BrowserType.launch(:chromium) 45 | 46 | on_exit(fn -> 47 | Playwright.Browser.close(browser) 48 | end) 49 | 50 | [browser: browser] 51 | end 52 | 53 | @tag :tmp_dir 54 | test "browser -> boombox -> mp4", %{browser: browser, tmp_dir: tmp_dir} do 55 | output_path = Path.join(tmp_dir, "/webrtc_to_mp4.mp4") 56 | 57 | boombox_task = 58 | Task.async(fn -> 59 | Boombox.run( 60 | input: {:webrtc, "ws://localhost:8829"}, 61 | output: output_path 62 | ) 63 | end) 64 | 65 | ingress_page = start_page(browser, "webrtc_from_browser") 66 | 67 | seconds = 10 68 | Process.sleep(seconds * 1000) 69 | 70 | assert_page_connected(ingress_page) 71 | assert_frames_encoded(ingress_page, seconds) 72 | 73 | close_page(ingress_page) 74 | 75 | Task.await(boombox_task) 76 | 77 | assert %{size: size} = File.stat!(output_path) 78 | # if things work fine, the size should be around ~850_000 79 | assert size > 400_000 80 | end 81 | 82 | @tag :tmp_dir 83 | test "browser -> (whip) boombox -> mp4", %{browser: browser, tmp_dir: tmp_dir} do 84 | output_path = Path.join(tmp_dir, "/webrtc_to_mp4.mp4") 85 | 86 | boombox_task = 87 | Task.async(fn -> 88 | Boombox.run( 89 | input: {:whip, "http://localhost:8829", token: "whip_it!"}, 90 | output: output_path 91 | ) 92 | end) 93 | 94 | ingress_page = start_page(browser, "whip") 95 | seconds = 10 96 | Process.sleep(seconds * 1000) 97 | 98 | assert_page_connected(ingress_page) 99 | assert_frames_encoded(ingress_page, seconds) 100 | 101 | close_page(ingress_page) 102 | 103 | Task.await(boombox_task) 104 | 105 | assert %{size: size} = File.stat!(output_path) 106 | # if things work fine, the size should be around ~850_000 107 | assert size > 400_000 108 | end 109 | 110 | for first <- [:ingress, :egress], transcoding_policy <- [:always, :if_needed] do 111 | test "browser -> boombox -> browser, but #{first} browser page connects first and :transcoding_policy option is set to #{transcoding_policy}", 112 | %{ 113 | browser: browser 114 | } do 115 | boombox_task = 116 | Task.async(fn -> 117 | Boombox.run( 118 | input: {:webrtc, "ws://localhost:8829"}, 119 | output: 120 | {:webrtc, "ws://localhost:8830", transcoding_policy: unquote(transcoding_policy)} 121 | ) 122 | end) 123 | 124 | {ingress_page, egress_page} = 125 | case unquote(first) do 126 | :ingress -> 127 | ingress_page = start_page(browser, "webrtc_from_browser") 128 | Process.sleep(500) 129 | egress_page = start_page(browser, "webrtc_to_browser") 130 | {ingress_page, egress_page} 131 | 132 | :egress -> 133 | egress_page = start_page(browser, "webrtc_to_browser") 134 | Process.sleep(500) 135 | ingress_page = start_page(browser, "webrtc_from_browser") 136 | {ingress_page, egress_page} 137 | end 138 | 139 | seconds = 10 140 | Process.sleep(seconds * 1000) 141 | 142 | [ingress_page, egress_page] 143 | |> Enum.each(&assert_page_connected/1) 144 | 145 | assert_frames_encoded(ingress_page, seconds) 146 | assert_frames_decoded(egress_page, seconds) 147 | 148 | assert_page_codecs(ingress_page, [:vp8, :opus]) 149 | 150 | egress_video_codec = 151 | unquote( 152 | case transcoding_policy do 153 | :always -> :h264 154 | :if_needed -> :vp8 155 | end 156 | ) 157 | 158 | assert_page_codecs(egress_page, [egress_video_codec, :opus]) 159 | 160 | [ingress_page, egress_page] 161 | |> Enum.each(&close_page/1) 162 | 163 | Task.await(boombox_task) 164 | end 165 | end 166 | 167 | test "boombox -> browser (bouncing logo)", %{browser: browser} do 168 | overlay = 169 | __DIR__ 170 | |> Path.join("fixtures/logo.png") 171 | |> Image.open!() 172 | 173 | bg = Image.new!(640, 480, color: :light_gray) 174 | max_x = Image.width(bg) - Image.width(overlay) 175 | max_y = Image.height(bg) - Image.height(overlay) 176 | 177 | stream = 178 | Stream.iterate({_x = 300, _y = 0, _dx = 1, _dy = 2, _pts = 0}, fn {x, y, dx, dy, pts} -> 179 | dx = if (x + dx) in 0..max_x, do: dx, else: -dx 180 | dy = if (y + dy) in 0..max_y, do: dy, else: -dy 181 | pts = pts + div(Membrane.Time.seconds(1), _fps = 60) 182 | {x + dx, y + dy, dx, dy, pts} 183 | end) 184 | |> Stream.map(fn {x, y, _dx, _dy, pts} -> 185 | img = Image.compose!(bg, overlay, x: x, y: y) 186 | %Boombox.Packet{kind: :video, payload: img, pts: pts} 187 | end) 188 | 189 | boombox_task = 190 | Task.async(fn -> 191 | stream 192 | |> Boombox.run( 193 | input: {:stream, video: :image, audio: false}, 194 | output: {:webrtc, "ws://localhost:8830"} 195 | ) 196 | end) 197 | 198 | page = start_page(browser, "webrtc_to_browser") 199 | 200 | seconds = 10 201 | Process.sleep(seconds * 1000) 202 | 203 | assert_page_connected(page) 204 | assert_frames_decoded(page, seconds) 205 | 206 | close_page(page) 207 | Task.shutdown(boombox_task) 208 | end 209 | 210 | defp start_page(browser, page) do 211 | url = "http://localhost:#{@port}/#{page}.html" 212 | do_start_page(browser, url) 213 | end 214 | 215 | defp do_start_page(browser, url) do 216 | page = Playwright.Browser.new_page(browser) 217 | 218 | response = Playwright.Page.goto(page, url) 219 | assert response.status == 200 220 | 221 | Playwright.Page.click(page, "button[id=\"button\"]") 222 | 223 | page 224 | end 225 | 226 | defp close_page(page) do 227 | Playwright.Page.click(page, "button[id=\"button\"]") 228 | Playwright.Page.close(page) 229 | end 230 | 231 | defp assert_page_connected(page) do 232 | assert page 233 | |> Playwright.Page.text_content("[id=\"status\"]") 234 | |> String.contains?("Connected") 235 | end 236 | 237 | defp assert_page_codecs(page, codecs) do 238 | page_codecs = 239 | get_webrtc_stats(page, type: "codec") 240 | |> MapSet.new(& &1.mimeType) 241 | 242 | expected_codecs = 243 | codecs 244 | |> MapSet.new(fn 245 | :h264 -> "video/H264" 246 | :vp8 -> "video/VP8" 247 | :opus -> "audio/opus" 248 | end) 249 | 250 | assert page_codecs == expected_codecs 251 | end 252 | 253 | defp assert_frames_encoded(page, time_seconds) do 254 | fps_lowerbound = 12 255 | 256 | [%{framesEncoded: frames_encoded}] = 257 | get_webrtc_stats(page, type: "outbound-rtp", kind: "video") 258 | 259 | assert frames_encoded >= time_seconds * fps_lowerbound 260 | end 261 | 262 | defp assert_frames_decoded(page, time_seconds) do 263 | fps_lowerbound = 12 264 | 265 | [%{framesDecoded: frames_decoded}] = 266 | get_webrtc_stats(page, type: "inbound-rtp", kind: "video") 267 | 268 | assert frames_decoded >= time_seconds * fps_lowerbound 269 | end 270 | 271 | defp get_webrtc_stats(page, constraints) do 272 | js_fuj = 273 | "async () => {const stats = await window.pc.getStats(null); return Array.from(stats)}" 274 | 275 | Playwright.Page.evaluate(page, js_fuj) 276 | |> Enum.map(fn [_id, data] -> data end) 277 | |> Enum.filter(fn stat -> Enum.all?(constraints, fn {k, v} -> stat[k] == v end) end) 278 | end 279 | end 280 | -------------------------------------------------------------------------------- /test/fixtures/bun10s.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/bun10s.mp4 -------------------------------------------------------------------------------- /test/fixtures/bun10s_a.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/bun10s_a.mp4 -------------------------------------------------------------------------------- /test/fixtures/bun10s_h265.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/bun10s_h265.mp4 -------------------------------------------------------------------------------- /test/fixtures/bun10s_v.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/bun10s_v.mp4 -------------------------------------------------------------------------------- /test/fixtures/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/logo.png -------------------------------------------------------------------------------- /test/fixtures/ref_bouncing_bubble.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bouncing_bubble.mp4 -------------------------------------------------------------------------------- /test/fixtures/ref_bun.pcm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun.pcm -------------------------------------------------------------------------------- /test/fixtures/ref_bun10s_aac.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun10s_aac.mp4 -------------------------------------------------------------------------------- /test/fixtures/ref_bun10s_aac2.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun10s_aac2.mp4 -------------------------------------------------------------------------------- /test/fixtures/ref_bun10s_aac_hls/g3cFdmlkZW8.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:7 3 | #EXT-X-TARGETDURATION:5 4 | #EXT-X-MEDIA-SEQUENCE:0 5 | #EXT-X-DISCONTINUITY-SEQUENCE:0 6 | #EXT-X-MAP:URI="muxed_header_g3cFdmlkZW8_part_0.mp4" 7 | #EXTINF:4.804, 8 | muxed_segment_0_g3cFdmlkZW8.m4s 9 | #EXTINF:4.2875, 10 | muxed_segment_1_g3cFdmlkZW8.m4s 11 | #EXTINF:0.914, 12 | muxed_segment_2_g3cFdmlkZW8.m4s 13 | #EXT-X-ENDLIST 14 | -------------------------------------------------------------------------------- /test/fixtures/ref_bun10s_aac_hls/index.m3u8: -------------------------------------------------------------------------------- 1 | #EXTM3U 2 | #EXT-X-VERSION:7 3 | #EXT-X-INDEPENDENT-SEGMENTS 4 | #EXT-X-STREAM-INF:BANDWIDTH=1176810,AVERAGE-BANDWIDTH=972120,RESOLUTION=480x270,CODECS=",mp4a.40.2" 5 | g3cFdmlkZW8.m3u8 -------------------------------------------------------------------------------- /test/fixtures/ref_bun10s_aac_hls/muxed_header_g3cFdmlkZW8_part_0.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun10s_aac_hls/muxed_header_g3cFdmlkZW8_part_0.mp4 -------------------------------------------------------------------------------- /test/fixtures/ref_bun10s_aac_hls/muxed_segment_0_g3cFdmlkZW8.m4s: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun10s_aac_hls/muxed_segment_0_g3cFdmlkZW8.m4s -------------------------------------------------------------------------------- /test/fixtures/ref_bun10s_aac_hls/muxed_segment_1_g3cFdmlkZW8.m4s: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun10s_aac_hls/muxed_segment_1_g3cFdmlkZW8.m4s -------------------------------------------------------------------------------- /test/fixtures/ref_bun10s_aac_hls/muxed_segment_2_g3cFdmlkZW8.m4s: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun10s_aac_hls/muxed_segment_2_g3cFdmlkZW8.m4s -------------------------------------------------------------------------------- /test/fixtures/ref_bun10s_h265.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun10s_h265.mp4 -------------------------------------------------------------------------------- /test/fixtures/ref_bun10s_opus2_aac.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun10s_opus2_aac.mp4 -------------------------------------------------------------------------------- /test/fixtures/ref_bun10s_opus_aac.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun10s_opus_aac.mp4 -------------------------------------------------------------------------------- /test/fixtures/ref_bun_imgs.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun_imgs.mp4 -------------------------------------------------------------------------------- /test/fixtures/ref_bun_rotated.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/ref_bun_rotated.mp4 -------------------------------------------------------------------------------- /test/fixtures/sherlock.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/sherlock.mp4 -------------------------------------------------------------------------------- /test/fixtures/storage_endpoints/bun10s.aac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/storage_endpoints/bun10s.aac -------------------------------------------------------------------------------- /test/fixtures/storage_endpoints/bun10s.h264: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/storage_endpoints/bun10s.h264 -------------------------------------------------------------------------------- /test/fixtures/storage_endpoints/bun10s.h265: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/storage_endpoints/bun10s.h265 -------------------------------------------------------------------------------- /test/fixtures/storage_endpoints/bun10s.ivf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/storage_endpoints/bun10s.ivf -------------------------------------------------------------------------------- /test/fixtures/storage_endpoints/bun10s.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/storage_endpoints/bun10s.mp3 -------------------------------------------------------------------------------- /test/fixtures/storage_endpoints/bun10s.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/storage_endpoints/bun10s.mp4 -------------------------------------------------------------------------------- /test/fixtures/storage_endpoints/bun10s.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/storage_endpoints/bun10s.ogg -------------------------------------------------------------------------------- /test/fixtures/storage_endpoints/bun10s.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/storage_endpoints/bun10s.wav -------------------------------------------------------------------------------- /test/fixtures/storage_endpoints/bun10s_video.msr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/boombox/7344f9c0b9dc487a5ce864d1180a91c515a6a3e3/test/fixtures/storage_endpoints/bun10s_video.msr -------------------------------------------------------------------------------- /test/rtp/config_parsing_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Boombox.InternalBin.RTP.ParsingTest do 2 | use ExUnit.Case 3 | 4 | alias Boombox.InternalBin.Ready 5 | 6 | describe "Parsing RTP options succeeds" do 7 | test "for correct AAC + H264 options" do 8 | rtp_opts = 9 | [ 10 | port: 5001, 11 | audio_encoding: :AAC, 12 | audio_payload_type: 100, 13 | audio_clock_rate: 1234, 14 | aac_bitrate_mode: :hbr, 15 | audio_specific_config: Base.decode16!("1210"), 16 | video_encoding: :H264, 17 | video_payload_type: 101, 18 | video_clock_rate: 6789, 19 | pps: "abc", 20 | sps: "def" 21 | ] 22 | 23 | assert %Ready{} = Boombox.InternalBin.RTP.create_input(rtp_opts) 24 | end 25 | 26 | test "for minimal H264 options" do 27 | rtp_opts = [port: 5001, video_encoding: :H264] 28 | 29 | assert %Ready{} = Boombox.InternalBin.RTP.create_input(rtp_opts) 30 | end 31 | end 32 | 33 | describe "Parsing RTP options fails" do 34 | test "for options with missing required encoding specific params" do 35 | rtp_opts = [port: 5001, audio_encoding: :AAC] 36 | 37 | assert_raise MatchError, fn -> Boombox.InternalBin.RTP.create_input(rtp_opts) end 38 | end 39 | 40 | test "for no tracks configured" do 41 | rtp_opts = [port: 5001] 42 | 43 | assert_raise RuntimeError, fn -> Boombox.InternalBin.RTP.create_input(rtp_opts) end 44 | end 45 | 46 | test "for no options provided" do 47 | rtp_opts = [] 48 | 49 | assert_raise RuntimeError, fn -> Boombox.InternalBin.RTP.create_input(rtp_opts) end 50 | end 51 | 52 | test "for unsupported encoding" do 53 | rtp_opts = [port: 5001, video_encoding: :Glorp] 54 | 55 | assert_raise RuntimeError, fn -> Boombox.InternalBin.RTP.create_input(rtp_opts) end 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /test/support/async.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Async do 2 | @moduledoc false 3 | # Helper for creating asynchronous tests 4 | # - creates a public function instead of a test 5 | # - creates a module with a single test that calls said function 6 | # - copies all @tags to the newly created module 7 | # - setup and setup_all won't work (yet) 8 | 9 | defmacro async_test( 10 | test_name, 11 | context \\ quote do 12 | %{} 13 | end, 14 | do: block 15 | ) do 16 | quote do 17 | id = :crypto.strong_rand_bytes(12) |> Base.encode16() 18 | test_module_name = Module.concat(__MODULE__, "AsyncTest_#{id}") 19 | fun_name = :"async_test_#{id}" 20 | after_compile_fun_name = :"async_test_ac_#{id}" 21 | 22 | @tags_attrs [:tag, :describetag, :moduletag] 23 | |> Enum.flat_map(fn attr -> 24 | Module.get_attribute(__MODULE__, attr) 25 | |> Enum.map(&{attr, &1}) 26 | end) 27 | 28 | def unquote(unquoted_var(:fun_name))(unquote(context)) do 29 | unquote(block) 30 | end 31 | 32 | def unquote(unquoted_var(:after_compile_fun_name))(_bytecode, _env) do 33 | test_name = unquote(unquoted(test_name)) 34 | fun_name = unquote(unquoted_var(:fun_name)) 35 | 36 | content = 37 | quote do 38 | use ExUnit.Case, async: true 39 | 40 | Enum.each(unquote(@tags_attrs), fn {name, value} -> 41 | Module.put_attribute(__MODULE__, name, value) 42 | end) 43 | 44 | test unquote(test_name), context do 45 | unquote(__MODULE__).unquote(fun_name)(context) 46 | end 47 | end 48 | 49 | Module.create(unquote(unquoted_var(:test_module_name)), content, __ENV__) 50 | end 51 | 52 | @after_compile {__MODULE__, after_compile_fun_name} 53 | 54 | Module.delete_attribute(__MODULE__, :tag) 55 | end 56 | end 57 | 58 | defp unquoted_var(name) do 59 | unquoted(Macro.var(name, __MODULE__)) 60 | end 61 | 62 | defp unquoted(ast) do 63 | {:unquote, [], [ast]} 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /test/support/compare.ex: -------------------------------------------------------------------------------- 1 | defmodule Support.Compare do 2 | @moduledoc false 3 | 4 | import ExUnit.Assertions 5 | import Membrane.ChildrenSpec 6 | import Membrane.Testing.Assertions 7 | 8 | require Membrane.Pad, as: Pad 9 | 10 | alias Membrane.Testing 11 | 12 | @type compare_option :: 13 | {:kinds, [:audio | :video]} 14 | | {:format, :mp4 | :hls} 15 | | {:subject_terminated_early, boolean()} 16 | 17 | defmodule GetBuffers do 18 | @moduledoc false 19 | use Membrane.Sink 20 | 21 | def_input_pad :input, accepted_format: _any 22 | 23 | @impl true 24 | def handle_init(_ctx, _opts) do 25 | {[], %{acc: []}} 26 | end 27 | 28 | @impl true 29 | def handle_buffer(:input, buffer, _ctx, state) do 30 | {[], %{acc: [buffer | state.acc]}} 31 | end 32 | 33 | @impl true 34 | def handle_end_of_stream(:input, _ctx, state) do 35 | {[notify_parent: {:buffers, Enum.reverse(state.acc)}], state} 36 | end 37 | end 38 | 39 | @spec compare(Path.t(), Path.t(), [compare_option()]) :: :ok 40 | # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity 41 | def compare(subject, reference, options \\ []) do 42 | kinds = options[:kinds] || [:audio, :video] 43 | format = options[:format] || :mp4 44 | subject_terminated_early = options[:subject_terminated_early] || false 45 | p = Testing.Pipeline.start_link_supervised!() 46 | 47 | Testing.Pipeline.execute_actions(p, spec: get_source_spec(subject, reference, format)) 48 | 49 | assert_pipeline_notified(p, :ref_demuxer, {:new_tracks, ref_tracks}) 50 | 51 | ref_spec = 52 | Enum.map(ref_tracks, fn 53 | {id, %Membrane.AAC{}} -> 54 | get_child(:ref_demuxer) 55 | |> via_out(Pad.ref(:output, id)) 56 | |> child(Membrane.AAC.Parser) 57 | |> child(Membrane.AAC.FDK.Decoder) 58 | |> child(:ref_audio_bufs, GetBuffers) 59 | 60 | {id, %h26x{}} when h26x in [Membrane.H264, Membrane.H265] -> 61 | {parser, decoder} = get_h26x_parser_and_decoder(h26x) 62 | 63 | get_child(:ref_demuxer) 64 | |> via_out(Pad.ref(:output, id)) 65 | |> child(parser) 66 | |> child(decoder) 67 | |> child(:ref_video_bufs, GetBuffers) 68 | end) 69 | 70 | assert_pipeline_notified(p, :sub_demuxer, {:new_tracks, sub_tracks}) 71 | 72 | expected_subject_tracks_number = options[:expected_subject_tracks_number] || length(kinds) 73 | assert length(sub_tracks) == expected_subject_tracks_number 74 | 75 | sub_spec = 76 | Enum.map(sub_tracks, fn 77 | {id, %Membrane.AAC{}} -> 78 | if options[:expected_subject_tracks_number] == nil do 79 | assert :audio in kinds 80 | end 81 | 82 | get_child(:sub_demuxer) 83 | |> via_out(Pad.ref(:output, id)) 84 | |> child(Membrane.AAC.Parser) 85 | |> child(Membrane.AAC.FDK.Decoder) 86 | |> child(%Membrane.FFmpeg.SWResample.Converter{ 87 | output_stream_format: %Membrane.RawAudio{ 88 | sample_format: :s16le, 89 | sample_rate: 44_100, 90 | channels: 1 91 | } 92 | }) 93 | |> child(:sub_audio_bufs, GetBuffers) 94 | 95 | {id, %h26x{}} when h26x in [Membrane.H264, Membrane.H265] -> 96 | if options[:expected_subject_tracks_number] == nil do 97 | assert :video in kinds 98 | end 99 | 100 | {parser, decoder} = get_h26x_parser_and_decoder(h26x) 101 | 102 | get_child(:sub_demuxer) 103 | |> via_out(Pad.ref(:output, id)) 104 | |> child(parser) 105 | |> child(decoder) 106 | |> child(:sub_video_bufs, GetBuffers) 107 | end) 108 | 109 | Testing.Pipeline.execute_actions(p, spec: [ref_spec, sub_spec]) 110 | 111 | if :video in kinds do 112 | assert_pipeline_notified(p, :sub_video_bufs, {:buffers, sub_video_bufs}) 113 | assert_pipeline_notified(p, :ref_video_bufs, {:buffers, ref_video_bufs}) 114 | 115 | ref_video_bufs = 116 | if subject_terminated_early do 117 | Enum.take(ref_video_bufs, length(sub_video_bufs)) 118 | else 119 | ref_video_bufs 120 | end 121 | 122 | assert length(ref_video_bufs) == length(sub_video_bufs) 123 | 124 | Enum.zip(sub_video_bufs, ref_video_bufs) 125 | |> Enum.each(fn {sub, ref} -> 126 | # The results differ between operating systems 127 | # and subsequent runs due to transcoding. 128 | # The threshold here is obtained empirically and may need 129 | # to be adjusted, or a better metric should be used. 130 | boundary = options[:video_error_bounadry] || 10 131 | assert samples_min_squared_error(sub.payload, ref.payload, 8) < boundary 132 | end) 133 | end 134 | 135 | if :audio in kinds do 136 | assert_pipeline_notified(p, :sub_audio_bufs, {:buffers, sub_audio_bufs}) 137 | assert_pipeline_notified(p, :ref_audio_bufs, {:buffers, ref_audio_bufs}) 138 | 139 | ref_audio = Enum.map_join(ref_audio_bufs, & &1.payload) 140 | sub_audio = Enum.map_join(sub_audio_bufs, & &1.payload) 141 | assert byte_size(sub_audio) - byte_size(ref_audio) < 0.01 * byte_size(sub_audio) 142 | # The results differ between operating systems 143 | # and subsequent runs due to transcoding. 144 | # The threshold here is obtained empirically and may need 145 | # to be adjusted, or a better metric should be used. 146 | 147 | assert samples_min_squared_error(sub_audio, ref_audio, 16) < 30_000 148 | end 149 | 150 | Testing.Pipeline.terminate(p) 151 | end 152 | 153 | @spec samples_min_squared_error(binary, binary, pos_integer) :: float() 154 | def samples_min_squared_error(bin1, bin2, sample_size) do 155 | Enum.zip_with( 156 | for(<>, do: b), 157 | for(<>, do: b), 158 | fn b1, b2 -> (b1 - b2) ** 2 end 159 | ) 160 | |> then(&:math.sqrt(Enum.sum(&1) / length(&1))) 161 | end 162 | 163 | @spec get_source_spec(String.t(), String.t(), :mp4 | :hls) :: Membrane.ChildrenSpec.t() 164 | defp get_source_spec(subject, reference, format) do 165 | case format do 166 | :mp4 -> 167 | [ 168 | child(%Membrane.File.Source{location: subject, seekable?: true}) 169 | |> child(:sub_demuxer, %Membrane.MP4.Demuxer.ISOM{optimize_for_non_fast_start?: true}), 170 | child(%Membrane.File.Source{location: reference, seekable?: true}) 171 | |> child(:ref_demuxer, %Membrane.MP4.Demuxer.ISOM{optimize_for_non_fast_start?: true}) 172 | ] 173 | 174 | :hls -> 175 | [ 176 | child(:sub_demuxer, %Membrane.HTTPAdaptiveStream.Source{directory: subject}), 177 | child(:ref_demuxer, %Membrane.HTTPAdaptiveStream.Source{directory: reference}) 178 | ] 179 | end 180 | end 181 | 182 | defp get_h26x_parser_and_decoder(h26x) when h26x in [Membrane.H264, Membrane.H265] do 183 | parser = h26x |> Module.concat(Parser) |> struct!(output_stream_structure: :annexb) 184 | decoder = h26x |> Module.concat(FFmpeg.Decoder) 185 | {parser, decoder} 186 | end 187 | end 188 | -------------------------------------------------------------------------------- /test/support/hls/http_adaptive_stream_source.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.HTTPAdaptiveStream.Source do 2 | @moduledoc false 3 | 4 | use Membrane.Source 5 | 6 | alias Boombox.InternalBin.StorageEndpoints.MP4 7 | alias Membrane.{Buffer, MP4} 8 | alias Membrane.MP4.MovieBox.TrackBox 9 | 10 | def_options directory: [ 11 | spec: Path.t(), 12 | description: "directory containing hls files" 13 | ] 14 | 15 | def_output_pad :output, 16 | accepted_format: any_of(%Membrane.H264{}, %Membrane.AAC{}), 17 | availability: :on_request, 18 | flow_control: :manual, 19 | demand_unit: :buffers 20 | 21 | @impl true 22 | def handle_init(_ctx, opts) do 23 | hls_mode = 24 | case opts.directory |> File.ls!() |> Enum.find(&String.starts_with?(&1, "muxed_header")) do 25 | nil -> :separate_av 26 | _muxed_header -> :muxed_av 27 | end 28 | 29 | tracks_map = get_tracks(opts.directory, hls_mode) 30 | 31 | %{audio_buffers: audio_buffers, video_buffers: video_buffers} = 32 | get_buffers(opts.directory, hls_mode, tracks_map) 33 | 34 | state = 35 | %{ 36 | track_data: %{ 37 | audio: assemble_track_data(tracks_map.audio_track, audio_buffers), 38 | video: assemble_track_data(tracks_map.video_track, video_buffers) 39 | } 40 | } 41 | 42 | notification = 43 | state.track_data 44 | |> Enum.reject(&match?({_media, nil}, &1)) 45 | |> Enum.map(fn {media, %{stream_format: stream_format}} -> {media, stream_format} end) 46 | 47 | {[notify_parent: {:new_tracks, notification}], state} 48 | end 49 | 50 | @impl true 51 | def handle_pad_added(Pad.ref(:output, id) = pad, _ctx, state) do 52 | {[stream_format: {pad, state.track_data[id].stream_format}], state} 53 | end 54 | 55 | @impl true 56 | def handle_demand(Pad.ref(:output, id) = pad, demand_size, :buffers, _ctx, state) do 57 | {buffers_to_send, buffers_left} = state.track_data[id].buffers_left |> Enum.split(demand_size) 58 | 59 | actions = 60 | if buffers_left == [] do 61 | [buffer: {pad, buffers_to_send}, end_of_stream: pad] 62 | else 63 | [buffer: {pad, buffers_to_send}] 64 | end 65 | 66 | state = put_in(state, [:track_data, id, :buffers_left], buffers_left) 67 | 68 | {actions, state} 69 | end 70 | 71 | @spec get_prefixed_files(Path.t(), String.t()) :: [Path.t()] 72 | defp get_prefixed_files(directory, prefix) do 73 | File.ls!(directory) 74 | |> Enum.filter(&String.starts_with?(&1, prefix)) 75 | |> Enum.map(&Path.join(directory, &1)) 76 | end 77 | 78 | @spec get_tracks(Path.t(), hls_mode :: :muxed_av | :separate_av) :: 79 | %{audio_track: MP4.Track.t(), video_track: MP4.Track.t()} 80 | defp get_tracks(directory, :muxed_av) do 81 | {parsed_header, ""} = 82 | get_prefixed_files(directory, "muxed_header") 83 | |> List.first() 84 | |> File.read!() 85 | |> MP4.Container.parse!() 86 | 87 | parsed_header[:moov].children 88 | |> Keyword.get_values(:trak) 89 | |> Enum.map(&TrackBox.unpack/1) 90 | |> Enum.reduce(%{audio_track: nil, video_track: nil}, fn track, tracks_map -> 91 | case track.stream_format do 92 | %Membrane.AAC{} -> %{tracks_map | audio_track: track} 93 | %Membrane.H264{} -> %{tracks_map | video_track: track} 94 | _other -> tracks_map 95 | end 96 | end) 97 | end 98 | 99 | defp get_tracks(directory, :separate_av) do 100 | %{ 101 | audio_track: get_separate_track(directory, :audio), 102 | video_track: get_separate_track(directory, :video) 103 | } 104 | end 105 | 106 | @spec get_separate_track(Path.t(), :audio | :video) :: MP4.Track.t() | nil 107 | defp get_separate_track(directory, media) do 108 | header_prefix = 109 | case media do 110 | :audio -> "audio_header" 111 | :video -> "video_header" 112 | end 113 | 114 | case get_prefixed_files(directory, header_prefix) |> List.first() do 115 | nil -> 116 | nil 117 | 118 | header -> 119 | {parsed_header, ""} = 120 | header 121 | |> File.read!() 122 | |> MP4.Container.parse!() 123 | 124 | TrackBox.unpack(parsed_header[:moov].children[:trak]) 125 | end 126 | end 127 | 128 | @spec get_buffers( 129 | Path.t(), 130 | hls_mode :: :muxed_av | :separate_av, 131 | %{audio_track: MP4.Track.t() | nil, video_track: MP4.Track.t() | nil} 132 | ) :: %{audio_buffers: [Buffer.t()], video_buffers: [Buffer.t()]} 133 | defp get_buffers( 134 | directory, 135 | :muxed_av, 136 | %{audio_track: %MP4.Track{id: audio_id}, video_track: %MP4.Track{id: video_id}} 137 | ) do 138 | segments_filenames = get_prefixed_files(directory, "muxed_segment") |> Enum.sort() 139 | 140 | Enum.map(segments_filenames, fn file -> 141 | %{^audio_id => segment_audio_buffers, ^video_id => segment_video_buffers} = 142 | get_buffers_from_muxed_segment(file) 143 | 144 | {segment_audio_buffers, segment_video_buffers} 145 | end) 146 | |> Enum.unzip() 147 | |> then(fn {audio_buffers, video_buffers} -> 148 | %{audio_buffers: List.flatten(audio_buffers), video_buffers: List.flatten(video_buffers)} 149 | end) 150 | end 151 | 152 | defp get_buffers(directory, :separate_av, %{audio_track: audio_track, video_track: video_track}) do 153 | %{ 154 | audio_buffers: audio_track && get_separate_buffers(directory, :audio), 155 | video_buffers: video_track && get_separate_buffers(directory, :video) 156 | } 157 | end 158 | 159 | @spec get_separate_buffers(Path.t(), :audio | :video) :: [Buffer.t()] 160 | defp get_separate_buffers(directory, media) do 161 | segment_prefix = 162 | case media do 163 | :audio -> "audio_segment" 164 | :video -> "video_segment" 165 | end 166 | 167 | case get_prefixed_files(directory, segment_prefix) |> Enum.sort() do 168 | [] -> 169 | nil 170 | 171 | segment_filenames -> 172 | Enum.flat_map(segment_filenames, fn segment_filename -> 173 | {container, ""} = segment_filename |> File.read!() |> MP4.Container.parse!() 174 | 175 | sample_lengths = 176 | container[:moof].children[:traf].children[:trun].fields.samples 177 | |> Enum.map(& &1.sample_size) 178 | 179 | samples_binary = container[:mdat].content 180 | 181 | get_buffers_from_samples(sample_lengths, samples_binary) 182 | end) 183 | end 184 | end 185 | 186 | @spec get_buffers_from_muxed_segment(Path.t()) :: %{(track_id :: pos_integer()) => [Buffer.t()]} 187 | defp get_buffers_from_muxed_segment(segment_filename) do 188 | {container, ""} = segment_filename |> File.read!() |> MP4.Container.parse!() 189 | 190 | Enum.zip( 191 | Keyword.get_values(container, :moof), 192 | Keyword.get_values(container, :mdat) 193 | ) 194 | |> Map.new(fn {moof_box, mdat_box} -> 195 | traf_box_children = moof_box.children[:traf].children 196 | 197 | sample_sizes = 198 | traf_box_children[:trun].fields.samples 199 | |> Enum.map(& &1.sample_size) 200 | 201 | buffers = get_buffers_from_samples(sample_sizes, mdat_box.content) 202 | 203 | {traf_box_children[:tfhd].fields.track_id, buffers} 204 | end) 205 | end 206 | 207 | @spec get_buffers_from_samples([pos_integer()], binary()) :: [Buffer.t()] 208 | defp get_buffers_from_samples([], <<>>) do 209 | [] 210 | end 211 | 212 | defp get_buffers_from_samples([first_sample_length | sample_lengths_rest], samples_binary) do 213 | <> = samples_binary 214 | 215 | [ 216 | %Buffer{payload: payload} 217 | | get_buffers_from_samples(sample_lengths_rest, samples_binary_rest) 218 | ] 219 | end 220 | 221 | @spec assemble_track_data(MP4.Track.t() | nil, [Buffer.t()] | nil) :: 222 | %{stream_format: Membrane.H264.t() | Membrane.AAC.t(), buffers_left: [Buffer.t()]} | nil 223 | defp assemble_track_data(track, buffers) do 224 | case track do 225 | nil -> 226 | nil 227 | 228 | %MP4.Track{stream_format: stream_format} -> 229 | %{stream_format: stream_format, buffers_left: buffers} 230 | end 231 | end 232 | end 233 | -------------------------------------------------------------------------------- /test/support/rtsp/server/handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.Support.RTSP.Server.Handler do 2 | @moduledoc false 3 | 4 | use Membrane.RTSP.Server.Handler 5 | 6 | require Membrane.Logger 7 | 8 | alias Membrane.RTSP.Response 9 | @pt 96 10 | @clock_rate 90_000 11 | 12 | @impl true 13 | def init(config) do 14 | config 15 | |> Map.put(:pipeline_pid, nil) 16 | |> Map.put(:socket, nil) 17 | end 18 | 19 | @impl true 20 | def handle_open_connection(conn, state) do 21 | %{state | socket: conn} 22 | end 23 | 24 | @impl true 25 | def handle_describe(_req, state) do 26 | sdp = """ 27 | v=0 28 | m=video 0 RTP/AVP 96 29 | a=control:/control 30 | a=rtpmap:#{@pt} H264/#{@clock_rate} 31 | a=fmtp:#{@pt} packetization-mode=1 32 | """ 33 | 34 | response = 35 | Response.new(200) 36 | |> Response.with_header("Content-Type", "application/sdp") 37 | |> Response.with_body(sdp) 38 | 39 | {response, state} 40 | end 41 | 42 | @impl true 43 | def handle_setup(_req, state) do 44 | {Response.new(200), state} 45 | end 46 | 47 | @impl true 48 | def handle_play(configured_media_context, state) do 49 | media_context = configured_media_context |> Map.values() |> List.first() 50 | 51 | {client_rtp_port, _client_rtcp_port} = media_context.client_port 52 | 53 | arg = %{ 54 | socket: state.socket, 55 | ssrc: media_context.ssrc, 56 | pt: @pt, 57 | clock_rate: @clock_rate, 58 | client_port: client_rtp_port, 59 | client_ip: media_context.address, 60 | server_rtp_socket: media_context.rtp_socket, 61 | fixture_path: state.fixture_path 62 | } 63 | 64 | {:ok, _sup_pid, pipeline_pid} = 65 | Membrane.Support.RTSP.Server.Pipeline.start_link(arg) 66 | 67 | {Response.new(200), %{state | pipeline_pid: pipeline_pid}} 68 | end 69 | 70 | @impl true 71 | def handle_pause(state) do 72 | {Response.new(501), state} 73 | end 74 | 75 | @impl true 76 | def handle_teardown(state) do 77 | {Response.new(200), state} 78 | end 79 | 80 | @impl true 81 | def handle_closed_connection(_state), do: :ok 82 | end 83 | -------------------------------------------------------------------------------- /test/support/rtsp/server/pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.Support.RTSP.Server.Pipeline do 2 | @moduledoc false 3 | 4 | use Membrane.Pipeline 5 | 6 | @spec start_link(map()) :: Membrane.Pipeline.on_start() 7 | def start_link(config) do 8 | Membrane.Pipeline.start_link(__MODULE__, config) 9 | end 10 | 11 | @impl true 12 | def handle_init(_ctx, opts) do 13 | spec = 14 | child(:mp4_in_file_source, %Membrane.File.Source{ 15 | location: opts.fixture_path, 16 | seekable?: true 17 | }) 18 | |> child(:mp4_demuxer, %Membrane.MP4.Demuxer.ISOM{optimize_for_non_fast_start?: true}) 19 | 20 | {[spec: spec], opts} 21 | end 22 | 23 | @impl true 24 | def handle_child_notification({:new_tracks, tracks}, :mp4_demuxer, _ctx, state) do 25 | spec = 26 | Enum.map(tracks, fn 27 | {id, %Membrane.AAC{}} -> 28 | get_child(:mp4_demuxer) 29 | |> via_out(Pad.ref(:output, id)) 30 | |> child(Membrane.Debug.Sink) 31 | 32 | {id, %Membrane.H264{}} -> 33 | get_child(:mp4_demuxer) 34 | |> via_out(Pad.ref(:output, id)) 35 | |> child(:parser, %Membrane.H264.Parser{ 36 | output_alignment: :nalu, 37 | repeat_parameter_sets: true, 38 | skip_until_keyframe: true, 39 | output_stream_structure: :annexb 40 | }) 41 | |> via_in(Pad.ref(:input, state.ssrc), 42 | options: [payloader: Membrane.RTP.H264.Payloader] 43 | ) 44 | |> child(:rtp, Membrane.RTP.SessionBin) 45 | |> via_out(Pad.ref(:rtp_output, state.ssrc), 46 | options: [ 47 | payload_type: state.pt, 48 | clock_rate: state.clock_rate 49 | ] 50 | ) 51 | |> child(:udp_sink, %Membrane.UDP.Sink{ 52 | destination_address: state.client_ip, 53 | destination_port_no: state.client_port, 54 | local_socket: state.server_rtp_socket 55 | }) 56 | end) 57 | 58 | {[spec: spec], state} 59 | end 60 | 61 | @impl true 62 | def handle_child_notification(_notification, _element, _ctx, state) do 63 | {[], state} 64 | end 65 | 66 | @impl true 67 | def handle_element_end_of_stream(:udp_sink, :input, _ctx, state) do 68 | Process.sleep(50) 69 | :gen_tcp.close(state.socket) 70 | {[terminate: :normal], state} 71 | end 72 | 73 | @impl true 74 | def handle_element_end_of_stream(_child, _pad, _ctx, state) do 75 | {[], state} 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | max_cases = 2 | if System.get_env("CIRCLECI") == "true", 3 | do: 1, 4 | else: System.schedulers_online() * 2 5 | 6 | ExUnit.start(capture_log: true, max_cases: max_cases) 7 | --------------------------------------------------------------------------------