├── .circleci └── config.yml ├── .clang-format ├── .credo.exs ├── .editorconfig ├── .formatter.exs ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ └── please--open-new-issues-in-membranefranework-membrane_core.md └── workflows │ ├── on_issue_opened.yaml │ └── on_pr_opened.yaml ├── .gitignore ├── LICENSE ├── README.md ├── bundlex.exs ├── c_src └── membrane_rtmp_plugin │ ├── _generated │ └── .gitignore │ └── sink │ ├── _generated │ └── .gitignore │ ├── rtmp_sink.c │ ├── rtmp_sink.h │ └── rtmp_sink.spec.exs ├── examples ├── sink.exs ├── sink_video.exs ├── source.exs └── source_with_standalone_server.exs ├── lib └── membrane_rtmp_plugin │ ├── rtmp │ ├── amf0 │ │ ├── encoder.ex │ │ └── parser.ex │ ├── amf3 │ │ └── parser.ex │ ├── handshake.ex │ ├── handshake │ │ └── step.ex │ ├── header.ex │ ├── message.ex │ ├── message_handler.ex │ ├── message_parser.ex │ ├── messages │ │ ├── acknowledgement.ex │ │ ├── additional_media.ex │ │ ├── anonymous.ex │ │ ├── audio.ex │ │ ├── command │ │ │ ├── connect.ex │ │ │ ├── create_stream.ex │ │ │ ├── delete_stream.ex │ │ │ ├── fc_publish.ex │ │ │ ├── publish.ex │ │ │ └── release_stream.ex │ │ ├── on_expect_additional_media.ex │ │ ├── on_meta_data.ex │ │ ├── serializer.ex │ │ ├── set_chunk_size.ex │ │ ├── set_data_frame.ex │ │ ├── set_peer_bandwidth.ex │ │ ├── user_control.ex │ │ ├── video.ex │ │ └── window_acknowledgement.ex │ ├── responses.ex │ ├── sink │ │ ├── native.ex │ │ └── sink.ex │ └── source │ │ ├── client_handler_impl.ex │ │ ├── source.ex │ │ └── source_bin.ex │ ├── rtmp_server.ex │ └── rtmp_server │ ├── client_handler.ex │ └── listener.ex ├── mix.exs ├── mix.lock └── test ├── fixtures ├── audio.aac ├── audio.msr ├── bun33s.flv ├── bun33s_audio.flv ├── bun33s_video.flv ├── testsrc.flv ├── video.h264 └── video.msr ├── membrane_rtmp_plugin ├── rtmp_sink_test.exs └── rtmp_source_bin_test.exs ├── support ├── rtmp_source_with_builtin_server_test_pipeline.ex └── rtmp_source_with_external_server_test_pipeline.ex └── test_helper.exs /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2.1 2 | orbs: 3 | elixir: membraneframework/elixir@1 4 | 5 | workflows: 6 | version: 2 7 | build: 8 | jobs: 9 | - elixir/build_test: 10 | cache-version: 3 11 | filters: &filters 12 | tags: 13 | only: /v.*/ 14 | - elixir/test: 15 | cache-version: 3 16 | filters: 17 | <<: *filters 18 | - elixir/lint: 19 | cache-version: 3 20 | filters: 21 | <<: *filters 22 | - elixir/hex_publish: 23 | cache-version: 3 24 | requires: 25 | - elixir/build_test 26 | - elixir/test 27 | - elixir/lint 28 | context: 29 | - Deployment 30 | filters: 31 | branches: 32 | ignore: /.*/ 33 | tags: 34 | only: /v.*/ 35 | -------------------------------------------------------------------------------- /.clang-format: -------------------------------------------------------------------------------- 1 | BasedOnStyle: LLVM 2 | IndentWidth: 2 3 | -------------------------------------------------------------------------------- /.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 | [ 2 | inputs: [ 3 | "{lib,test,config,examples}/**/*.{ex,exs}", 4 | "c_src/**/*.spec.exs", 5 | ".formatter.exs", 6 | "*.exs" 7 | ], 8 | import_deps: [:membrane_core, :unifex] 9 | ] 10 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @varsill 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/please--open-new-issues-in-membranefranework-membrane_core.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Please, open new issues in membranefranework/membrane_core 3 | about: New issues related to this repo should be opened there 4 | title: "[DO NOT OPEN]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | Please, do not open this issue here. Open it in the [membrane_core](https://github.com/membraneframework/membrane_core) repository instead. 11 | 12 | Thanks for helping us grow :) 13 | -------------------------------------------------------------------------------- /.github/workflows/on_issue_opened.yaml: -------------------------------------------------------------------------------- 1 | name: 'Close issue when opened' 2 | on: 3 | issues: 4 | types: 5 | - opened 6 | jobs: 7 | close: 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: Close issue 15 | uses: ./.github/actions/close_issue 16 | with: 17 | GITHUB_TOKEN: ${{ secrets.MEMBRANEFRAMEWORKADMIN_TOKEN }} 18 | ISSUE_URL: ${{ github.event.issue.html_url }} 19 | ISSUE_NUMBER: ${{ github.event.issue.number }} 20 | REPOSITORY: ${{ github.repository }} 21 | -------------------------------------------------------------------------------- /.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 | compile_commands.json 2 | .gdb_history 3 | bundlex.sh 4 | bundlex.bat 5 | 6 | # Dir generated by tmp_dir ExUnit tag 7 | /tmp/ 8 | 9 | # Created by https://www.gitignore.io/api/c,vim,linux,macos,elixir,windows,visualstudiocode 10 | # Edit at https://www.gitignore.io/?templates=c,vim,linux,macos,elixir,windows,visualstudiocode 11 | 12 | ### C ### 13 | # Prerequisites 14 | *.d 15 | 16 | # Object files 17 | *.o 18 | *.ko 19 | *.obj 20 | *.elf 21 | 22 | # Linker output 23 | *.ilk 24 | *.map 25 | *.exp 26 | 27 | # Precompiled Headers 28 | *.gch 29 | *.pch 30 | 31 | # Libraries 32 | *.lib 33 | *.a 34 | *.la 35 | *.lo 36 | 37 | # Shared objects (inc. Windows DLLs) 38 | *.dll 39 | *.so 40 | *.so.* 41 | *.dylib 42 | 43 | # Executables 44 | *.exe 45 | *.out 46 | *.app 47 | *.i*86 48 | *.x86_64 49 | *.hex 50 | 51 | # Debug files 52 | *.dSYM/ 53 | *.su 54 | *.idb 55 | *.pdb 56 | 57 | # Kernel Module Compile Results 58 | *.mod* 59 | *.cmd 60 | .tmp_versions/ 61 | modules.order 62 | Module.symvers 63 | Mkfile.old 64 | dkms.conf 65 | 66 | ### Elixir ### 67 | /_build 68 | /cover 69 | /deps 70 | /doc 71 | /.fetch 72 | erl_crash.dump 73 | *.ez 74 | *.beam 75 | /config/*.secret.exs 76 | .elixir_ls/ 77 | 78 | ### Elixir Patch ### 79 | 80 | ### Linux ### 81 | *~ 82 | 83 | # temporary files which can be created if a process still has a handle open of a deleted file 84 | .fuse_hidden* 85 | 86 | # KDE directory preferences 87 | .directory 88 | 89 | # Linux trash folder which might appear on any partition or disk 90 | .Trash-* 91 | 92 | # .nfs files are created when an open file is removed but is still being accessed 93 | .nfs* 94 | 95 | ### macOS ### 96 | # General 97 | .DS_Store 98 | .AppleDouble 99 | .LSOverride 100 | 101 | # Icon must end with two \r 102 | Icon 103 | 104 | # Thumbnails 105 | ._* 106 | 107 | # Files that might appear in the root of a volume 108 | .DocumentRevisions-V100 109 | .fseventsd 110 | .Spotlight-V100 111 | .TemporaryItems 112 | .Trashes 113 | .VolumeIcon.icns 114 | .com.apple.timemachine.donotpresent 115 | 116 | # Directories potentially created on remote AFP share 117 | .AppleDB 118 | .AppleDesktop 119 | Network Trash Folder 120 | Temporary Items 121 | .apdisk 122 | 123 | ### Vim ### 124 | # Swap 125 | [._]*.s[a-v][a-z] 126 | [._]*.sw[a-p] 127 | [._]s[a-rt-v][a-z] 128 | [._]ss[a-gi-z] 129 | [._]sw[a-p] 130 | 131 | # Session 132 | Session.vim 133 | Sessionx.vim 134 | 135 | # Temporary 136 | .netrwhist 137 | # Auto-generated tag files 138 | tags 139 | # Persistent undo 140 | [._]*.un~ 141 | 142 | ### VisualStudioCode ### 143 | .vscode/* 144 | !.vscode/settings.json 145 | !.vscode/tasks.json 146 | !.vscode/launch.json 147 | !.vscode/extensions.json 148 | 149 | ### VisualStudioCode Patch ### 150 | # Ignore all local history of files 151 | .history 152 | 153 | ### Windows ### 154 | # Windows thumbnail cache files 155 | Thumbs.db 156 | Thumbs.db:encryptable 157 | ehthumbs.db 158 | ehthumbs_vista.db 159 | 160 | # Dump file 161 | *.stackdump 162 | 163 | # Folder config file 164 | [Dd]esktop.ini 165 | 166 | # Recycle Bin used on file shares 167 | $RECYCLE.BIN/ 168 | 169 | # Windows Installer files 170 | *.cab 171 | *.msi 172 | *.msix 173 | *.msm 174 | *.msp 175 | 176 | # Windows shortcuts 177 | *.lnk 178 | 179 | # End of https://www.gitignore.io/api/c,vim,linux,macos,elixir,windows,visualstudiocode 180 | -------------------------------------------------------------------------------- /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 | # Membrane RTMP Plugin 2 | 3 | [![Hex.pm](https://img.shields.io/hexpm/v/membrane_rtmp_plugin.svg)](https://hex.pm/packages/membrane_rtmp_plugin) 4 | [![API Docs](https://img.shields.io/badge/api-docs-yellow.svg?style=flat)](https://hexdocs.pm/membrane_rtmp_plugin) 5 | [![CircleCI](https://circleci.com/gh/membraneframework/membrane_rtmp_plugin.svg?style=svg)](https://circleci.com/gh/membraneframework/membrane_rtmp_plugin) 6 | 7 | This package provides RTMP server which receives an RTMP stream from a client and an element for streaming to an RTMP server. 8 | It is a part of [Membrane Multimedia Framework](https://membraneframework.org). 9 | 10 | ## Installation 11 | 12 | The package can be installed by adding `membrane_rtmp_plugin` to your list of dependencies in `mix.exs`: 13 | 14 | ```elixir 15 | def deps do 16 | [ 17 | {:membrane_rtmp_plugin, "~> 0.28.1"} 18 | ] 19 | end 20 | ``` 21 | 22 | The precompiled builds of the [ffmpeg](https://www.ffmpeg.org) will be pulled and linked automatically. However, should there be any problems, consider installing it manually. 23 | 24 | ### Manual instalation of dependencies 25 | 26 | #### macOS 27 | 28 | ```shell 29 | brew install ffmpeg 30 | ``` 31 | 32 | #### Ubuntu 33 | 34 | ```shell 35 | sudo apt-get install ffmpeg 36 | ``` 37 | 38 | #### Arch / Manjaro 39 | 40 | ```shell 41 | pacman -S ffmpeg 42 | ``` 43 | 44 | ## RTMP Server 45 | An simple RTMP server that accepts clients connecting on a given port and allows to distinguish between them 46 | based on app ID and stream key. Each client that has connected is asigned a dedicated client handler, which 47 | behaviour can be provided by RTMP server user. 48 | 49 | ## SourceBin 50 | 51 | Requires a client reference, which identifies a client handler that has been connected to the client, or an URL on which the client is supposed to connect. It receives RTMP stream, demuxes it and outputs H264 video and AAC audio. 52 | 53 | ## Client 54 | 55 | After establishing connection with server it waits to receive video and audio streams. Once both streams are received they are streamed to the server. 56 | Currently only the following codecs are supported: 57 | 58 | - H264 for video 59 | - AAC for audio 60 | 61 | ### Prerequisites 62 | 63 | In order to successfully build and install the plugin, you need to have **ffmpeg == 4.4** installed on your system 64 | 65 | ## Usage 66 | 67 | ### RTMP receiver 68 | 69 | Server-side example, in which Membrane element will act as an RTMP server and receive the stream, can be found under [`examples/source.exs`](examples/source.exs). Please note that 70 | this script allows only for a single client connecting to the RTMP server. 71 | Run it with: 72 | 73 | ```bash 74 | mix run examples/source.exs 75 | ``` 76 | 77 | When the server is ready you can connect to it with RTMP. If you just want to test it, you can use FFmpeg: 78 | 79 | ```bash 80 | ffmpeg -re -i test/fixtures/testsrc.flv -f flv -c:v copy -c:a copy rtmp://localhost:1935/app/stream_key 81 | ``` 82 | When the script terminates, the `testsrc` content should be available in the `received.flv` file. 83 | 84 | ### RTMP receive with standalone RTMP server 85 | 86 | If you want to see how you could setup the `Membrane.RTMPServer` on your own and use it 87 | with cooperation with the `Membane.RTMP.SourceBin`, take a look at [`examples/source_with_standalone_server.exs`](examples/source_with_standalone_server.exs) 88 | Run it with: 89 | 90 | ```bash 91 | mix run examples/source.exs 92 | ``` 93 | 94 | When the server is ready you can connect to it with RTMP. If you just want to test it, you can use FFmpeg: 95 | 96 | ```bash 97 | ffmpeg -re -i test/fixtures/testsrc.flv -f flv -c:v copy -c:a copy rtmp://localhost:1935/app/stream_key 98 | ``` 99 | When the script terminates, the `testsrc` content should be available in the `received.flv` file. 100 | 101 | ### Streaming with RTMP 102 | 103 | Streaming implementation example is provided with the following [`examples/sink.exs`](examples/sink.exs). Run it with: 104 | 105 | ```bash 106 | elixir examples/sink.exs 107 | ``` 108 | 109 | If you are interested in streaming only a single track. e.g. video, use [`examples/sink_video.exs`](examples/sink_video.exs) instead: 110 | 111 | ```bash 112 | elixir examples/sink_video.exs 113 | ``` 114 | 115 | It will connect to RTMP server provided via URL and stream H264 video and AAC audio. 116 | RTMP server that will receive this stream can be launched with ffmpeg by running the following commands: 117 | 118 | ```bash 119 | ffmpeg -y -listen 1 -f flv -i rtmp://localhost:1935 -c copy dest.flv 120 | ``` 121 | 122 | It will receive stream and once streaming is completed dump it to .flv file. If you are using the command above, please remember to run it **before** the streaming script. 123 | 124 | ## Copyright and License 125 | 126 | Copyright 2021, [Software Mansion](https://swmansion.com/?utm_source=git&utm_medium=readme&utm_campaign=membrane_rtmp_plugin) 127 | 128 | [![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=membrane_rtmp_plugin) 129 | 130 | Licensed under the [Apache License, Version 2.0](LICENSE) 131 | -------------------------------------------------------------------------------- /bundlex.exs: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.BundlexProject do 2 | use Bundlex.Project 3 | 4 | def project do 5 | [ 6 | natives: natives() 7 | ] 8 | end 9 | 10 | defp natives() do 11 | [ 12 | rtmp_sink: [ 13 | sources: ["sink/rtmp_sink.c"], 14 | deps: [unifex: :unifex], 15 | interface: :nif, 16 | preprocessor: Unifex, 17 | os_deps: [ 18 | ffmpeg: [ 19 | {:precompiled, Membrane.PrecompiledDependencyProvider.get_dependency_url(:ffmpeg), 20 | ["libavformat", "libavutil"]}, 21 | {:pkg_config, ["libavformat", "libavutil"]} 22 | ] 23 | ] 24 | ] 25 | ] 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /c_src/membrane_rtmp_plugin/_generated/.gitignore: -------------------------------------------------------------------------------- 1 | **/*.h 2 | **/*.c 3 | **/*.cpp 4 | -------------------------------------------------------------------------------- /c_src/membrane_rtmp_plugin/sink/_generated/.gitignore: -------------------------------------------------------------------------------- 1 | **/*.h 2 | **/*.c 3 | **/*.cpp 4 | -------------------------------------------------------------------------------- /c_src/membrane_rtmp_plugin/sink/rtmp_sink.c: -------------------------------------------------------------------------------- 1 | #include "rtmp_sink.h" 2 | #include 3 | 4 | const AVRational MEMBRANE_TIME_BASE = (AVRational){1, 1000000000}; 5 | 6 | void handle_init_state(State *state); 7 | 8 | static bool is_ready(State *state); 9 | 10 | UNIFEX_TERM create(UnifexEnv *env, char *rtmp_url, int audio_present, 11 | int video_present) { 12 | State *state = unifex_alloc_state(env); 13 | handle_init_state(state); 14 | 15 | state->audio_present = audio_present; 16 | state->video_present = video_present; 17 | 18 | UNIFEX_TERM create_result; 19 | avformat_alloc_output_context2(&state->output_ctx, NULL, "flv", rtmp_url); 20 | if (!state->output_ctx) { 21 | create_result = 22 | create_result_error(env, "Failed to initialize output context"); 23 | goto end; 24 | } 25 | create_result = create_result_ok(env, state); 26 | end: 27 | unifex_release_state(env, state); 28 | return create_result; 29 | } 30 | 31 | UNIFEX_TERM try_connect(UnifexEnv *env, State *state) { 32 | const char *rtmp_url = state->output_ctx->url; 33 | if (!(state->output_ctx->oformat->flags & AVFMT_NOFILE)) { 34 | int av_err = avio_open(&state->output_ctx->pb, rtmp_url, AVIO_FLAG_WRITE); 35 | if (av_err == AVERROR(ECONNREFUSED)) { 36 | return try_connect_result_error_econnrefused(env); 37 | } else if (av_err == AVERROR(ETIMEDOUT)) { 38 | return try_connect_result_error_etimedout(env); 39 | } else if (av_err < 0) { 40 | return try_connect_result_error(env, av_err2str(av_err)); 41 | } 42 | } 43 | return try_connect_result_ok(env); 44 | } 45 | 46 | UNIFEX_TERM flush_and_close_stream(UnifexEnv *env, State *state) { 47 | if (!state->output_ctx || state->closed || !is_ready(state)) { 48 | return finalize_stream_result_ok(env); 49 | } 50 | if (av_write_trailer(state->output_ctx)) { 51 | return unifex_raise(env, "Failed writing stream trailer"); 52 | } 53 | avio_close(state->output_ctx->pb); 54 | avformat_free_context(state->output_ctx); 55 | state->closed = true; 56 | return finalize_stream_result_ok(env); 57 | } 58 | 59 | UNIFEX_TERM finalize_stream(UnifexEnv *env, State *state) { 60 | // Retained for backward compatibility. 61 | return flush_and_close_stream(env, state); 62 | } 63 | 64 | UNIFEX_TERM init_video_stream(UnifexEnv *env, State *state, int width, 65 | int height, UnifexPayload *avc_config) { 66 | AVStream *video_stream; 67 | if (state->video_stream_index != -1) { 68 | return init_video_stream_result_error_stream_format_resent(env); 69 | } 70 | 71 | video_stream = avformat_new_stream(state->output_ctx, NULL); 72 | if (!video_stream) { 73 | return unifex_raise(env, "Failed allocation video stream"); 74 | } 75 | state->video_stream_index = video_stream->index; 76 | 77 | video_stream->codecpar->codec_type = AVMEDIA_TYPE_VIDEO; 78 | video_stream->codecpar->codec_id = AV_CODEC_ID_H264; 79 | video_stream->codecpar->width = width; 80 | video_stream->codecpar->height = height; 81 | 82 | video_stream->codecpar->extradata_size = avc_config->size; 83 | video_stream->codecpar->extradata = 84 | (uint8_t *)av_malloc(avc_config->size + AV_INPUT_BUFFER_PADDING_SIZE); 85 | if (!video_stream->codecpar->extradata) { 86 | return unifex_raise(env, 87 | "Failed allocating video stream configuration data"); 88 | } 89 | memcpy(video_stream->codecpar->extradata, avc_config->data, avc_config->size); 90 | 91 | bool ready = is_ready(state); 92 | if (ready && !state->header_written) { 93 | if (avformat_write_header(state->output_ctx, NULL) < 0) { 94 | return unifex_raise(env, "Failed writing header"); 95 | } 96 | state->header_written = true; 97 | } 98 | return init_video_stream_result_ok(env, ready, state); 99 | } 100 | 101 | UNIFEX_TERM init_audio_stream(UnifexEnv *env, State *state, int channels, 102 | int sample_rate, UnifexPayload *aac_config) { 103 | AVStream *audio_stream; 104 | if (state->audio_stream_index != -1) { 105 | return init_audio_stream_result_error_stream_format_resent(env); 106 | } 107 | 108 | audio_stream = avformat_new_stream(state->output_ctx, NULL); 109 | if (!audio_stream) { 110 | return unifex_raise(env, "Failed allocating audio stream"); 111 | } 112 | state->audio_stream_index = audio_stream->index; 113 | 114 | AVChannelLayout *channel_layout = malloc(sizeof *channel_layout); 115 | if (!channel_layout) { 116 | return unifex_raise(env, "Failed allocating channel layout"); 117 | } 118 | av_channel_layout_default(channel_layout, channels); 119 | 120 | audio_stream->codecpar->codec_type = AVMEDIA_TYPE_AUDIO; 121 | audio_stream->codecpar->codec_id = AV_CODEC_ID_AAC; 122 | audio_stream->codecpar->sample_rate = sample_rate; 123 | audio_stream->codecpar->ch_layout = *channel_layout; 124 | audio_stream->codecpar->extradata_size = aac_config->size; 125 | audio_stream->codecpar->extradata = 126 | (uint8_t *)av_malloc(aac_config->size + AV_INPUT_BUFFER_PADDING_SIZE); 127 | 128 | if (!audio_stream->codecpar->extradata) { 129 | return unifex_raise(env, "Failed allocating audio stream extradata"); 130 | } 131 | memcpy(audio_stream->codecpar->extradata, aac_config->data, aac_config->size); 132 | 133 | bool ready = is_ready(state); 134 | if (ready && !state->header_written) { 135 | if (avformat_write_header(state->output_ctx, NULL) < 0) { 136 | return unifex_raise(env, "Failed writing header"); 137 | } 138 | state->header_written = true; 139 | } 140 | return init_audio_stream_result_ok(env, ready, state); 141 | } 142 | 143 | UNIFEX_TERM write_video_frame(UnifexEnv *env, State *state, 144 | UnifexPayload *frame, int64_t dts, int64_t pts, 145 | int is_key_frame) { 146 | if (state->video_stream_index == -1) { 147 | return write_video_frame_result_error( 148 | env, 149 | "Video stream is not initialized. Stream format has not been received"); 150 | } 151 | 152 | AVRational video_stream_time_base = 153 | state->output_ctx->streams[state->video_stream_index]->time_base; 154 | AVPacket *packet = av_packet_alloc(); 155 | 156 | uint8_t *data = (uint8_t *)av_malloc(frame->size); 157 | memcpy(data, frame->data, frame->size); 158 | av_packet_from_data(packet, data, frame->size); 159 | 160 | UNIFEX_TERM write_frame_result; 161 | 162 | if (is_key_frame) { 163 | packet->flags |= AV_PKT_FLAG_KEY; 164 | } 165 | 166 | packet->stream_index = state->video_stream_index; 167 | 168 | if (!packet->data) { 169 | write_frame_result = 170 | unifex_raise(env, "Failed allocating video frame data"); 171 | goto end; 172 | } 173 | 174 | int64_t dts_scaled = 175 | av_rescale_q(dts, MEMBRANE_TIME_BASE, video_stream_time_base); 176 | int64_t pts_scaled = 177 | av_rescale_q(pts, MEMBRANE_TIME_BASE, video_stream_time_base); 178 | packet->dts = dts_scaled; 179 | packet->pts = pts_scaled; 180 | 181 | packet->duration = dts_scaled - state->current_video_dts; 182 | state->current_video_dts = dts_scaled; 183 | 184 | int result = av_write_frame(state->output_ctx, packet); 185 | 186 | if (result) { 187 | write_frame_result = 188 | write_video_frame_result_error(env, av_err2str(result)); 189 | goto end; 190 | } 191 | write_frame_result = write_video_frame_result_ok(env, state); 192 | 193 | end: 194 | av_packet_free(&packet); 195 | return write_frame_result; 196 | } 197 | 198 | UNIFEX_TERM write_audio_frame(UnifexEnv *env, State *state, 199 | UnifexPayload *frame, int64_t pts) { 200 | if (state->audio_stream_index == -1) { 201 | return write_audio_frame_result_error( 202 | env, "Audio stream has not been initialized. Stream format has not " 203 | "been received"); 204 | } 205 | 206 | AVRational audio_stream_time_base = 207 | state->output_ctx->streams[state->audio_stream_index]->time_base; 208 | AVPacket *packet = av_packet_alloc(); 209 | 210 | uint8_t *data = (uint8_t *)av_malloc(frame->size); 211 | memcpy(data, frame->data, frame->size); 212 | av_packet_from_data(packet, data, frame->size); 213 | 214 | UNIFEX_TERM write_frame_result; 215 | 216 | packet->stream_index = state->audio_stream_index; 217 | 218 | if (!packet->data) { 219 | write_frame_result = 220 | unifex_raise(env, "Failed allocating audio frame data."); 221 | goto end; 222 | } 223 | 224 | int64_t pts_scaled = 225 | av_rescale_q(pts, MEMBRANE_TIME_BASE, audio_stream_time_base); 226 | // Packet DTS is set to PTS since AAC buffers do not contain DTS 227 | packet->dts = pts_scaled; 228 | packet->pts = pts_scaled; 229 | 230 | packet->duration = pts_scaled - state->current_audio_pts; 231 | state->current_audio_pts = pts_scaled; 232 | 233 | int result = av_write_frame(state->output_ctx, packet); 234 | 235 | if (result) { 236 | write_frame_result = 237 | write_audio_frame_result_error(env, av_err2str(result)); 238 | goto end; 239 | } 240 | write_frame_result = write_audio_frame_result_ok(env, state); 241 | 242 | end: 243 | av_packet_free(&packet); 244 | return write_frame_result; 245 | } 246 | 247 | void handle_init_state(State *state) { 248 | state->video_stream_index = -1; 249 | state->current_video_dts = 0; 250 | 251 | state->audio_stream_index = -1; 252 | state->current_audio_pts = 0; 253 | 254 | state->header_written = false; 255 | state->closed = false; 256 | 257 | state->output_ctx = NULL; 258 | } 259 | 260 | void handle_destroy_state(UnifexEnv *env, State *state) { 261 | UNIFEX_UNUSED(env); 262 | if (!state->closed) 263 | flush_and_close_stream(env, state); 264 | } 265 | 266 | bool is_ready(State *state) { 267 | return (!state->audio_present || state->audio_stream_index != -1) && 268 | (!state->video_present || state->video_stream_index != -1); 269 | } 270 | -------------------------------------------------------------------------------- /c_src/membrane_rtmp_plugin/sink/rtmp_sink.h: -------------------------------------------------------------------------------- 1 | #pragma once 2 | 3 | #include 4 | #include 5 | #include 6 | #include 7 | 8 | typedef struct State State; 9 | 10 | struct State { 11 | AVFormatContext *output_ctx; 12 | 13 | bool audio_present; 14 | bool video_present; 15 | bool closed; 16 | 17 | int video_stream_index; 18 | int64_t current_video_dts; 19 | 20 | int audio_stream_index; 21 | int64_t current_audio_pts; 22 | 23 | bool header_written; 24 | }; 25 | 26 | #include "_generated/rtmp_sink.h" 27 | -------------------------------------------------------------------------------- /c_src/membrane_rtmp_plugin/sink/rtmp_sink.spec.exs: -------------------------------------------------------------------------------- 1 | module Membrane.RTMP.Sink.Native 2 | 3 | state_type "State" 4 | interface [NIF] 5 | 6 | spec create(rtmp_url :: string, audio_present :: bool, video_present :: bool) :: 7 | {:ok :: label, state} | {:error :: label, reason :: string} 8 | 9 | # WARN: connect will conflict with POSIX function name 10 | spec try_connect(state) :: 11 | (:ok :: label) 12 | | {:error :: label, :econnrefused :: label} 13 | | {:error :: label, :etimedout :: label} 14 | | {:error :: label, reason :: string} 15 | 16 | spec finalize_stream(state) :: :ok :: label 17 | 18 | spec init_video_stream(state, width :: int, height :: int, avc_config :: payload) :: 19 | {:ok :: label, ready :: bool, state} | {:error :: label, :stream_format_resent :: label} 20 | 21 | spec write_video_frame(state, frame :: payload, dts :: int64, pts :: int64, is_key_frame :: bool) :: 22 | {:ok :: label, state} | {:error :: label, reason :: string} 23 | 24 | spec init_audio_stream(state, channels :: int, sample_rate :: int, aac_config :: payload) :: 25 | {:ok :: label, ready :: bool, state} | {:error :: label, :stream_format_resent :: label} 26 | 27 | spec write_audio_frame(state, frame :: payload, pts :: int64) :: 28 | {:ok :: label, state} | {:error :: label, reason :: string} 29 | 30 | dirty :io, write_video_frame: 5, write_audio_frame: 3, try_connect: 1 31 | -------------------------------------------------------------------------------- /examples/sink.exs: -------------------------------------------------------------------------------- 1 | # Before running this example, make sure that target RTMP server is live. 2 | # If you are streaming to eg. Youtube, you don't need to worry about it. 3 | # If you want to test it locally, you can run the FFmpeg server with: 4 | # ffmpeg -y -listen 1 -f flv -i rtmp://localhost:1935 -c copy dest.flv 5 | 6 | Logger.configure(level: :info) 7 | 8 | Mix.install([ 9 | :membrane_realtimer_plugin, 10 | :membrane_hackney_plugin, 11 | :membrane_h264_plugin, 12 | :membrane_aac_plugin, 13 | {:membrane_rtmp_plugin, path: __DIR__ |> Path.join("..") |> Path.expand()} 14 | ]) 15 | 16 | defmodule Example do 17 | use Membrane.Pipeline 18 | 19 | @samples_url "https://raw.githubusercontent.com/membraneframework/static/gh-pages/samples/big-buck-bunny/" 20 | @video_url @samples_url <> "bun33s_480x270.h264" 21 | @audio_url @samples_url <> "bun33s.aac" 22 | 23 | @impl true 24 | def handle_init(_ctx, destination: destination) do 25 | structure = [ 26 | child(:video_source, %Membrane.Hackney.Source{ 27 | location: @video_url, 28 | hackney_opts: [follow_redirect: true] 29 | }) 30 | |> child(:video_parser, %Membrane.H264.Parser{ 31 | generate_best_effort_timestamps: %{framerate: {25, 1}}, 32 | output_stream_structure: :avc1 33 | }) 34 | |> child(:video_realtimer, Membrane.Realtimer) 35 | |> via_in(Pad.ref(:video, 0)) 36 | |> child(:rtmp_sink, %Membrane.RTMP.Sink{rtmp_url: destination}), 37 | child(:audio_source, %Membrane.Hackney.Source{ 38 | location: @audio_url, 39 | hackney_opts: [follow_redirect: true] 40 | }) 41 | |> child(:audio_parser, %Membrane.AAC.Parser{ 42 | out_encapsulation: :none 43 | }) 44 | |> child(:audio_realtimer, Membrane.Realtimer) 45 | |> via_in(Pad.ref(:audio, 0)) 46 | |> get_child(:rtmp_sink) 47 | ] 48 | 49 | {[spec: structure], %{streams_to_end: 2}} 50 | end 51 | 52 | # The rest of the example module is only used for self-termination of the pipeline after processing finishes 53 | @impl true 54 | def handle_element_end_of_stream(:rtmp_sink, _pad, _ctx, %{streams_to_end: 1} = state) do 55 | {[terminate: :shutdown], %{state | streams_to_end: 0}} 56 | end 57 | 58 | @impl true 59 | def handle_element_end_of_stream(:rtmp_sink, _pad, _ctx, state) do 60 | {[], %{state | streams_to_end: 1}} 61 | end 62 | 63 | @impl true 64 | def handle_element_end_of_stream(_child, _pad, _ctx, state) do 65 | {[], state} 66 | end 67 | end 68 | 69 | destination = System.get_env("RTMP_URL", "rtmp://localhost:1935") 70 | 71 | # Initialize the pipeline and start it 72 | {:ok, _supervisor, pipeline} = Membrane.Pipeline.start_link(Example, destination: destination) 73 | 74 | monitor_ref = Process.monitor(pipeline) 75 | 76 | # Wait for the pipeline to finish 77 | receive do 78 | {:DOWN, ^monitor_ref, :process, _pid, _reason} -> 79 | :ok 80 | end 81 | -------------------------------------------------------------------------------- /examples/sink_video.exs: -------------------------------------------------------------------------------- 1 | # Before running this example, make sure that target RTMP server is live. 2 | # If you are streaming to eg. Youtube, you don't need to worry about it. 3 | # If you want to test it locally, you can run the FFmpeg server with: 4 | # ffmpeg -y -listen 1 -f flv -i rtmp://localhost:1935 -c copy dest.flv 5 | 6 | Logger.configure(level: :info) 7 | 8 | Mix.install([ 9 | :membrane_realtimer_plugin, 10 | :membrane_hackney_plugin, 11 | :membrane_h264_plugin, 12 | {:membrane_rtmp_plugin, path: __DIR__ |> Path.join("..") |> Path.expand()} 13 | ]) 14 | 15 | defmodule Example do 16 | use Membrane.Pipeline 17 | 18 | @video_url "https://raw.githubusercontent.com/membraneframework/static/gh-pages/samples/big-buck-bunny/bun33s_480x270.h264" 19 | 20 | @impl true 21 | def handle_init(_ctx, destination: destination) do 22 | structure = [ 23 | child(:video_source, %Membrane.Hackney.Source{ 24 | location: @video_url, 25 | hackney_opts: [follow_redirect: true] 26 | }) 27 | |> child(:video_parser, %Membrane.H264.Parser{ 28 | output_stream_structure: :avc3, 29 | generate_best_effort_timestamps: %{framerate: {25, 1}} 30 | }) 31 | |> child(:video_realtimer, Membrane.Realtimer) 32 | |> via_in(Pad.ref(:video, 0)) 33 | |> child(:rtmp_sink, %Membrane.RTMP.Sink{rtmp_url: destination, tracks: [:video]}) 34 | ] 35 | 36 | {[spec: structure], %{}} 37 | end 38 | 39 | # The rest of the example module is only used for self-termination of the pipeline after processing finishes 40 | @impl true 41 | def handle_element_end_of_stream(:rtmp_sink, _pad, _ctx, state) do 42 | {[terminate: :normal], state} 43 | end 44 | 45 | @impl true 46 | def handle_element_end_of_stream(_child, _pad, _ctx, state) do 47 | {[], state} 48 | end 49 | end 50 | 51 | destination = System.get_env("RTMP_URL", "rtmp://localhost:1935") 52 | 53 | # Initialize the pipeline and start it 54 | {:ok, _supervisor, pipeline} = Membrane.Pipeline.start_link(Example, destination: destination) 55 | 56 | monitor_ref = Process.monitor(pipeline) 57 | 58 | # Wait for the pipeline to finish 59 | receive do 60 | {:DOWN, ^monitor_ref, :process, _pid, _reason} -> 61 | :ok 62 | end 63 | -------------------------------------------------------------------------------- /examples/source.exs: -------------------------------------------------------------------------------- 1 | # After running this script, you can access the server at rtmp://localhost:1935 2 | # You can use FFmpeg to stream to it 3 | # ffmpeg -re -i test/fixtures/testsrc.flv -f flv -c:v copy -c:a copy rtmp://localhost:1935/app/stream_key 4 | 5 | defmodule Pipeline do 6 | use Membrane.Pipeline 7 | 8 | @output_file "received.flv" 9 | 10 | @impl true 11 | def handle_init(_ctx, _opts) do 12 | structure = [ 13 | child(:source, %Membrane.RTMP.SourceBin{ 14 | url: "rtmp://127.0.0.1:1935/app/stream_key" 15 | }) 16 | |> via_out(:audio) 17 | |> child(:audio_parser, %Membrane.AAC.Parser{ 18 | out_encapsulation: :none, 19 | output_config: :audio_specific_config 20 | }) 21 | |> via_in(Pad.ref(:audio, 0)) 22 | |> child(:muxer, Membrane.FLV.Muxer) 23 | |> child(:sink, %Membrane.File.Sink{location: @output_file}), 24 | get_child(:source) 25 | |> via_out(:video) 26 | |> child(:video_parser, %Membrane.H264.Parser{ 27 | output_stream_structure: :avc1 28 | }) 29 | |> via_in(Pad.ref(:video, 0)) 30 | |> get_child(:muxer) 31 | ] 32 | 33 | {[spec: structure], %{}} 34 | end 35 | 36 | # The rest of the module is used for self-termination of the pipeline after processing finishes 37 | @impl true 38 | def handle_element_end_of_stream(:sink, _pad, _ctx, state) do 39 | {[terminate: :normal], state} 40 | end 41 | 42 | @impl true 43 | def handle_element_end_of_stream(_child, _pad, _ctx, state) do 44 | {[], state} 45 | end 46 | end 47 | 48 | # Start a pipeline with `Membrane.RTMP.Source` that will spawn an RTMP server waiting for 49 | # the client connection on given URL 50 | {:ok, _supervisor, pipeline} = Membrane.Pipeline.start_link(Pipeline) 51 | 52 | # Wait for the pipeline to terminate itself 53 | ref = Process.monitor(pipeline) 54 | 55 | :ok = 56 | receive do 57 | {:DOWN, ^ref, _process, ^pipeline, :normal} -> :ok 58 | end 59 | -------------------------------------------------------------------------------- /examples/source_with_standalone_server.exs: -------------------------------------------------------------------------------- 1 | # After running this script, you can access the server at rtmp://localhost:1935 2 | # You can use FFmpeg to stream to it 3 | # ffmpeg -re -i test/fixtures/testsrc.flv -f flv -c:v copy -c:a copy rtmp://localhost:1935/app/stream_key 4 | 5 | defmodule Pipeline do 6 | use Membrane.Pipeline 7 | 8 | @output_file "received.flv" 9 | 10 | @impl true 11 | def handle_init(_ctx, opts) do 12 | structure = [ 13 | child(:source, %Membrane.RTMP.SourceBin{ 14 | client_ref: opts[:client_ref] 15 | }) 16 | |> via_out(:audio) 17 | |> child(:audio_parser, %Membrane.AAC.Parser{ 18 | out_encapsulation: :none, 19 | output_config: :audio_specific_config 20 | }) 21 | |> via_in(Pad.ref(:audio, 0)) 22 | |> child(:muxer, Membrane.FLV.Muxer) 23 | |> child(:sink, %Membrane.File.Sink{location: @output_file}), 24 | get_child(:source) 25 | |> via_out(:video) 26 | |> child(:video_parser, %Membrane.H264.Parser{ 27 | output_stream_structure: :avc1 28 | }) 29 | |> via_in(Pad.ref(:video, 0)) 30 | |> get_child(:muxer) 31 | ] 32 | 33 | {[spec: structure], %{}} 34 | end 35 | 36 | # The rest of the module is used for self-termination of the pipeline after processing finishes 37 | @impl true 38 | def handle_element_end_of_stream(:sink, _pad, _ctx, state) do 39 | {[terminate: :normal], state} 40 | end 41 | 42 | @impl true 43 | def handle_element_end_of_stream(_child, _pad, _ctx, state) do 44 | {[], state} 45 | end 46 | end 47 | 48 | # The client will connect on `rtmp://localhost:1935/app/stream_key` 49 | port = 1935 50 | 51 | # example lambda function that upon launching will send client reference back to parent process. 52 | parent_process_pid = self() 53 | 54 | handle_new_client = fn client_ref, app, stream_key -> 55 | send(parent_process_pid, {:client_ref, client_ref, app, stream_key}) 56 | Membrane.RTMP.Source.ClientHandlerImpl 57 | end 58 | 59 | # Run the standalone server 60 | {:ok, server} = 61 | Membrane.RTMPServer.start_link( 62 | port: port, 63 | handle_new_client: handle_new_client 64 | ) 65 | 66 | app = "app" 67 | stream_key = "stream_key" 68 | 69 | # Wait max 10s for client to connect on /app/stream_key 70 | {:ok, client_ref} = 71 | receive do 72 | {:client_ref, client_ref, ^app, ^stream_key} -> 73 | {:ok, client_ref} 74 | after 75 | 10_000 -> :timeout 76 | end 77 | 78 | # Start the pipeline and provide it with the client_ref 79 | {:ok, _supervisor, pipeline} = 80 | Membrane.Pipeline.start_link(Pipeline, client_ref: client_ref) 81 | 82 | # Wait for the pipeline to terminate itself 83 | ref = Process.monitor(pipeline) 84 | 85 | :ok = 86 | receive do 87 | {:DOWN, ^ref, _process, ^pipeline, :normal} -> :ok 88 | end 89 | 90 | # Terminate the server 91 | Process.exit(server, :normal) 92 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/amf0/encoder.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.AMF0.Encoder do 2 | @moduledoc false 3 | 4 | @type basic_object_t :: float() | boolean() | String.t() | map() | :null 5 | @type list_entry_t :: {key :: String.t(), basic_object_t() | nil} 6 | @type object_t :: basic_object_t() | [list_entry_t()] 7 | 8 | @object_end_marker <<0x00, 0x00, 0x09>> 9 | 10 | @doc """ 11 | Encodes a message according to [AMF0](https://en.wikipedia.org/wiki/Action_Message_Format). 12 | """ 13 | @spec encode(object_t() | [object_t()]) :: binary() 14 | def encode(objects) when is_list(objects) do 15 | objects 16 | |> Enum.map(&do_encode_object/1) 17 | |> IO.iodata_to_binary() 18 | end 19 | 20 | def encode(object) do 21 | do_encode_object(object) 22 | end 23 | 24 | # encode number 25 | defp do_encode_object(object) when is_number(object) do 26 | <<0x00, object::float-size(64)>> 27 | end 28 | 29 | # encode boolean 30 | defp do_encode_object(object) when is_boolean(object) do 31 | if object do 32 | <<0x01, 1::8>> 33 | else 34 | <<0x01, 0::8>> 35 | end 36 | end 37 | 38 | # encode string 39 | defp do_encode_object(object) when is_binary(object) and byte_size(object) < 65_535 do 40 | <<0x02, byte_size(object)::16, object::binary>> 41 | end 42 | 43 | defp do_encode_object(object) when is_map(object) or is_list(object) do 44 | id = 45 | if is_map(object) do 46 | 0x03 47 | else 48 | 0x08 49 | end 50 | 51 | [id, Enum.map(object, &encode_key_value_pair/1), @object_end_marker] 52 | end 53 | 54 | defp do_encode_object(:null), do: <<0x05>> 55 | 56 | defp encode_key_value_pair({_key, nil}), do: [] 57 | 58 | defp encode_key_value_pair({<>, value}) when byte_size(key) < 65_535 do 59 | [<>, key, do_encode_object(value)] 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/amf0/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.AMF0.Parser do 2 | @moduledoc false 3 | 4 | alias Membrane.RTMP.AMF3 5 | 6 | @doc """ 7 | Parses message from [AMF0](https://en.wikipedia.org/wiki/Action_Message_Format#AMF0) format to Erlang terms. 8 | """ 9 | @spec parse(binary()) :: list() 10 | def parse(binary) do 11 | do_parse(binary, []) 12 | end 13 | 14 | defp do_parse(<<>>, acc), do: Enum.reverse(acc) 15 | 16 | defp do_parse(payload, acc) do 17 | {value, rest} = parse_value(payload) 18 | 19 | do_parse(rest, [value | acc]) 20 | end 21 | 22 | # parsing a number 23 | defp parse_value(<<0x00, number::float-size(64), rest::binary>>) do 24 | {number, rest} 25 | end 26 | 27 | # parsing a boolean 28 | defp parse_value(<<0x01, boolean::8, rest::binary>>) do 29 | {boolean == 1, rest} 30 | end 31 | 32 | # parsing a string 33 | defp parse_value(<<0x02, size::16, string::binary-size(size), rest::binary>>) do 34 | {string, rest} 35 | end 36 | 37 | # parsing a key-value object 38 | defp parse_value(<<0x03, rest::binary>>) do 39 | {acc, rest} = parse_object_pairs(rest, []) 40 | 41 | {Map.new(acc), rest} 42 | end 43 | 44 | # parsing a null value 45 | defp parse_value(<<0x05, rest::binary>>) do 46 | {:null, rest} 47 | end 48 | 49 | defp parse_value(<<0x08, _array_size::32, rest::binary>>) do 50 | parse_object_pairs(rest, []) 51 | end 52 | 53 | defp parse_value(<<0x0A, size::32, rest::binary>>) do 54 | {acc, rest} = 55 | Enum.reduce(1..size, {[], rest}, fn _i, {acc, rest} -> 56 | {value, rest} = parse_value(rest) 57 | 58 | {[value | acc], rest} 59 | end) 60 | 61 | {Enum.reverse(acc), rest} 62 | end 63 | 64 | # switch to AMF3 65 | defp parse_value(<<0x11, rest::binary>>) do 66 | AMF3.Parser.parse_one(rest) 67 | end 68 | 69 | defp parse_value(data) do 70 | raise "Unknown data type #{inspect(data)}" 71 | end 72 | 73 | # we reached object end 74 | defp parse_object_pairs(<<0x00, 0x00, 0x09, rest::binary>>, acc) do 75 | {Enum.reverse(acc), rest} 76 | end 77 | 78 | defp parse_object_pairs( 79 | <>, 80 | acc 81 | ) do 82 | {value, rest} = parse_value(rest) 83 | 84 | parse_object_pairs(rest, [{key, value} | acc]) 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/amf3/parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.AMF3.Parser do 2 | @moduledoc false 3 | 4 | import Bitwise 5 | 6 | @doc """ 7 | Parses message from [AMF3](https://en.wikipedia.org/wiki/Action_Message_Format#AMF3) format to Erlang terms. 8 | """ 9 | @spec parse(binary()) :: list() 10 | def parse(binary) do 11 | do_parse(binary, []) 12 | end 13 | 14 | @doc """ 15 | Parses a single message from [AMF3](https://en.wikipedia.org/wiki/Action_Message_Format#AMF3) format to Erlang terms. 16 | """ 17 | @spec parse_one(binary()) :: {value :: term(), rest :: binary()} 18 | def parse_one(binary) do 19 | parse_value(binary) 20 | end 21 | 22 | defp do_parse(<<>>, acc), do: Enum.reverse(acc) 23 | 24 | defp do_parse(payload, acc) do 25 | {value, rest} = parse_value(payload) 26 | 27 | do_parse(rest, [value | acc]) 28 | end 29 | 30 | # undefined 31 | defp parse_value(<<0x00, rest::binary>>) do 32 | {:undefined, rest} 33 | end 34 | 35 | # null 36 | defp parse_value(<<0x01, rest::binary>>) do 37 | {nil, rest} 38 | end 39 | 40 | # false 41 | defp parse_value(<<0x02, rest::binary>>) do 42 | {false, rest} 43 | end 44 | 45 | # true 46 | defp parse_value(<<0x03, rest::binary>>) do 47 | {true, rest} 48 | end 49 | 50 | # integer 51 | defp parse_value(<<0x04, rest::binary>>) do 52 | parse_integer(rest) 53 | end 54 | 55 | # double 56 | defp parse_value(<<0x05, double::float-size(64), rest::binary>>) do 57 | {double, rest} 58 | end 59 | 60 | # string 61 | defp parse_value(<<0x06, rest::binary>>) do 62 | parse_string(rest) 63 | end 64 | 65 | # xml document 66 | defp parse_value(<<0x07, rest::binary>>) do 67 | case check_value_type(rest) do 68 | {:value, size, rest} -> 69 | <> = rest 70 | 71 | {{:xml, string}, rest} 72 | 73 | {:ref, ref, rest} -> 74 | {{:ref, {:xml, ref}}, rest} 75 | end 76 | end 77 | 78 | # date 79 | defp parse_value(<<0x08, rest::binary>>) do 80 | case check_value_type(rest) do 81 | {:value, _value, rest} -> 82 | <> = rest 83 | 84 | {DateTime.from_unix!(trunc(date), :millisecond), rest} 85 | 86 | {:ref, ref, rest} -> 87 | {{:ref, {:date, ref}}, rest} 88 | end 89 | end 90 | 91 | # array 92 | defp parse_value(<<0x09, rest::binary>>) do 93 | case check_value_type(rest) do 94 | {:value, dense_array_size, rest} -> 95 | {assoc_array, rest} = parse_assoc_array(rest, []) 96 | {dense_array, rest} = parse_dense_array(rest, dense_array_size, []) 97 | 98 | {assoc_array ++ dense_array, rest} 99 | 100 | {:ref, ref, rest} -> 101 | {{:array_ref, ref}, rest} 102 | end 103 | end 104 | 105 | # object 106 | defp parse_value(<<0x0A, _rest::binary>>) do 107 | raise "Unsupported AMF3 type: object" 108 | end 109 | 110 | # xml 111 | defp parse_value(<<0x0B, rest::binary>>) do 112 | case check_value_type(rest) do 113 | {:value, size, rest} -> 114 | <> = rest 115 | 116 | {{:xml_script, string}, rest} 117 | 118 | {:ref, ref, rest} -> 119 | {{:ref, {:xml_script, ref}}, rest} 120 | end 121 | end 122 | 123 | # byte array 124 | defp parse_value(<<0x0C, rest::binary>>) do 125 | case check_value_type(rest) do 126 | {:value, size, rest} -> 127 | <> = rest 128 | 129 | {bytes, rest} 130 | 131 | {:ref, ref, rest} -> 132 | {{:ref, {:byte_array, ref}}, rest} 133 | end 134 | end 135 | 136 | # vector int 137 | defp parse_value(<<0x0D, _rest::binary>>) do 138 | raise "Unsupported AMF3 type: vector int" 139 | end 140 | 141 | # vector uint 142 | defp parse_value(<<0x0E, _rest::binary>>) do 143 | raise "Unsupported AMF3 type: vector uint" 144 | end 145 | 146 | # vector double 147 | defp parse_value(<<0x0F, _rest::binary>>) do 148 | raise "Unsupported AMF3 type: vector double" 149 | end 150 | 151 | # vector object 152 | defp parse_value(<<0x10, _rest::binary>>) do 153 | raise "Unsupported AMF3 type: vector object" 154 | end 155 | 156 | # dictionary 157 | defp parse_value(<<0x11, _rest::binary>>) do 158 | raise "Unsupported AMF3 type: dictionary" 159 | end 160 | 161 | defp parse_integer(<<0::1, value::7, rest::binary>>), do: {value, rest} 162 | 163 | defp parse_integer(<<1::1, first::7, 0::1, second::7, rest::binary>>) do 164 | {bsl(first, 7) + second, rest} 165 | end 166 | 167 | defp parse_integer(<<1::1, first::7, 1::1, second::7, 0::1, third::7, rest::binary>>) do 168 | {bsl(first, 14) + bsl(second, 7) + third, rest} 169 | end 170 | 171 | defp parse_integer(<<1::1, first::7, 1::1, second::7, 0::1, third::7, fourth::8, rest::binary>>) do 172 | {bsl(first, 22) + bsl(second, 15) + bsl(third, 7) + fourth, rest} 173 | end 174 | 175 | defp parse_string(payload) do 176 | case check_value_type(payload) do 177 | {:value, size, rest} -> 178 | <> = rest 179 | 180 | {string, rest} 181 | 182 | {:ref, ref, rest} -> 183 | {{:ref, {:string, ref}}, rest} 184 | end 185 | end 186 | 187 | defp parse_assoc_array(<<0x01, rest::binary>>, acc), do: {Enum.reverse(acc), rest} 188 | 189 | defp parse_assoc_array(payload, acc) do 190 | {key, rest} = parse_string(payload) 191 | {value, rest} = parse_value(rest) 192 | 193 | parse_assoc_array(rest, [{key, value} | acc]) 194 | end 195 | 196 | defp parse_dense_array(rest, 0, acc), do: {Enum.reverse(acc), rest} 197 | 198 | defp parse_dense_array(rest, size, acc) do 199 | {value, rest} = parse_value(rest) 200 | 201 | parse_dense_array(rest, size - 1, [value | acc]) 202 | end 203 | 204 | defp check_value_type(rest) do 205 | {number, rest} = parse_integer(rest) 206 | 207 | value = number >>> 1 208 | 209 | if (number &&& 0x01) == 1 do 210 | {:value, value, rest} 211 | else 212 | {:ref, value, rest} 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/handshake.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Handshake do 2 | @moduledoc false 3 | 4 | alias Membrane.RTMP.Handshake.Step 5 | 6 | defmodule State do 7 | @moduledoc false 8 | 9 | @enforce_keys [:step] 10 | defstruct @enforce_keys 11 | 12 | @type t :: %__MODULE__{ 13 | step: Step.t() | nil 14 | } 15 | end 16 | 17 | @c0_s0_size 1 18 | @handshake_size 1536 19 | 20 | @doc """ 21 | Initializes handshake process on a server side. 22 | """ 23 | @spec init_server() :: State.t() 24 | def init_server() do 25 | %State{step: nil} 26 | end 27 | 28 | @doc """ 29 | Initializes handshake process as a client. 30 | """ 31 | @spec init_client(non_neg_integer()) :: {Step.t(), State.t()} 32 | def init_client(epoch) do 33 | step = %Step{type: :c0_c1, data: generate_c1_s1(epoch)} 34 | 35 | {step, %State{step: step}} 36 | end 37 | 38 | @spec handle_step(binary(), State.t()) :: 39 | {:continue_handshake, Step.t(), State.t()} 40 | | {:handshake_finished, Step.t(), State.t()} 41 | | {:handshake_finished, State.t()} 42 | | {:error, {:invalid_handshake_step, Step.handshake_type_t()}} 43 | def handle_step(step_data, state) 44 | 45 | def handle_step(step_data, %State{step: %Step{type: :c0_c1} = previous_step}) do 46 | with {:ok, next_step} <- Step.deserialize(:s0_s1_s2, step_data), 47 | :ok <- Step.verify_next_step(previous_step, next_step) do 48 | <> = next_step.data 49 | 50 | step = %Step{type: :c2, data: s1} 51 | 52 | {:handshake_finished, step, %State{step: step}} 53 | end 54 | end 55 | 56 | def handle_step(step_data, %State{step: %Step{type: :s0_s1_s2} = previous_step}) do 57 | with {:ok, next_step} <- Step.deserialize(:c2, step_data), 58 | :ok <- Step.verify_next_step(previous_step, next_step) do 59 | {:handshake_finished, %State{step: next_step}} 60 | end 61 | end 62 | 63 | def handle_step(step_data, %State{step: nil}) do 64 | with {:ok, %Step{data: c1}} <- Step.deserialize(:c0_c1, step_data) do 65 | <> = c1 66 | 67 | step = %Step{ 68 | type: :s0_s1_s2, 69 | data: generate_c1_s1(time) <> c1 70 | } 71 | 72 | {:continue_handshake, step, %State{step: step}} 73 | end 74 | end 75 | 76 | @doc """ 77 | Returns how many bytes the next handshake step should consist of. 78 | """ 79 | @spec expects_bytes(State.t()) :: non_neg_integer() 80 | def expects_bytes(%State{step: step}) do 81 | case step do 82 | # expect c0 + c1 83 | nil -> 84 | @handshake_size + @c0_s0_size 85 | 86 | # expect s0 + s1 + s2 87 | %Step{type: :c0_c1} -> 88 | 2 * @handshake_size + @c0_s0_size 89 | 90 | # expect c2 91 | %Step{type: :s0_s1_s2} -> 92 | @handshake_size 93 | end 94 | end 95 | 96 | # generates a unique segment of the handshake's step 97 | # accordingly to the spec first 4 bytes are a connection epoch time, 98 | # followed by 4 zero bytes and 1528 random bytes 99 | defp generate_c1_s1(epoch) do 100 | <> 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/handshake/step.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Handshake.Step do 2 | @moduledoc false 3 | 4 | # Describes steps in the process of RTMP handshake 5 | 6 | require Membrane.Logger 7 | 8 | @enforce_keys [:data, :type] 9 | defstruct @enforce_keys 10 | 11 | @typedoc """ 12 | RTMP handshake types. 13 | 14 | The handshake flow between client and server looks as follows: 15 | 16 | +-------------+ +-------------+ 17 | | Client | TCP/IP Network | Server | 18 | +-------------+ | +-------------+ 19 | | | | 20 | Uninitialized | Uninitialized 21 | | C0 | | 22 | |------------------->| C0 | 23 | | |-------------------->| 24 | | C1 | | 25 | |------------------->| S0 | 26 | | |<--------------------| 27 | | | S1 | 28 | Version sent |<--------------------| 29 | | S0 | | 30 | |<-------------------| | 31 | | S1 | | 32 | |<-------------------| Version sent 33 | | | C1 | 34 | | |-------------------->| 35 | | C2 | | 36 | |------------------->| S2 | 37 | | |<--------------------| 38 | Ack sent | Ack Sent 39 | | S2 | | 40 | |<-------------------| | 41 | | | C2 | 42 | | |-------------------->| 43 | Handshake Done | Handshake Done 44 | | | | 45 | 46 | Where `C0` and `S0` are RTMP protocol version (set to 0x03). 47 | 48 | Both sides exchange random chunks of 1536 bytes and the other side is supposed to 49 | respond with those bytes remaining unchanged. 50 | 51 | In case of `S1` and `S2`, the latter is supposed to be equal to `C1` while 52 | the client has to respond by sending `C2` with the `S1` as the value. 53 | """ 54 | @type handshake_type_t :: :c0_c1 | :s0_s1_s2 | :c2 55 | 56 | @type t :: %__MODULE__{ 57 | data: binary(), 58 | type: handshake_type_t() 59 | } 60 | 61 | @rtmp_version 0x03 62 | 63 | @handshake_size 1536 64 | @s1_s2_size 2 * @handshake_size 65 | 66 | @doc """ 67 | Serializes the step. 68 | """ 69 | @spec serialize(t()) :: binary() 70 | def serialize(%__MODULE__{type: type, data: data}) when type in [:c0_c1, :s0_s1_s2] do 71 | <<@rtmp_version, data::binary>> 72 | end 73 | 74 | def serialize(%__MODULE__{data: data}), do: data 75 | 76 | @doc """ 77 | Deserializes the handshake step given the type. 78 | """ 79 | @spec deserialize(handshake_type_t(), binary()) :: 80 | {:ok, t()} | {:error, {:invalid_handshake_step, handshake_type_t()}} 81 | def deserialize(:c0_c1 = type, <<0x03, data::binary-size(@handshake_size)>>) do 82 | {:ok, %__MODULE__{type: type, data: data}} 83 | end 84 | 85 | def deserialize(:s0_s1_s2 = type, <<0x03, data::binary-size(@s1_s2_size)>>) do 86 | {:ok, %__MODULE__{type: type, data: data}} 87 | end 88 | 89 | def deserialize(:c2 = type, <>) do 90 | {:ok, %__MODULE__{type: type, data: data}} 91 | end 92 | 93 | def deserialize(type, _data), do: {:error, {:invalid_handshake_step, type}} 94 | 95 | @doc """ 96 | Verifies if the following handshake step matches the previous one. 97 | 98 | C1 should have the same value as S2 and C2 be the same as S1 (except the read timestamp). 99 | """ 100 | @spec verify_next_step(t() | nil, t()) :: 101 | :ok | {:error, {:invalid_handshake_step, handshake_type_t()}} 102 | def verify_next_step(previous_step, next_step) 103 | 104 | def verify_next_step(nil, %__MODULE__{type: :c0_c1}), do: :ok 105 | 106 | def verify_next_step(%__MODULE__{type: :c0_c1, data: c1}, %__MODULE__{ 107 | type: :s0_s1_s2 = type, 108 | data: s1_s2 109 | }) do 110 | <<_s1::binary-size(@handshake_size), s2::binary-size(@handshake_size)>> = s1_s2 111 | 112 | c1 = decompose(c1) 113 | s2 = decompose(s2) 114 | 115 | unless s2.time_sent == c1.time_sent and s2.data == c1.data do 116 | Membrane.Logger.warning( 117 | "Invalid handshake step #{type}: the servers's response doesn't match the client payload" 118 | ) 119 | end 120 | 121 | :ok 122 | end 123 | 124 | def verify_next_step(%__MODULE__{type: :s0_s1_s2, data: s1_s2}, %__MODULE__{ 125 | type: :c2 = type, 126 | data: c2 127 | }) do 128 | <> = s1_s2 129 | 130 | s1 = decompose(s1) 131 | c2 = decompose(c2) 132 | 133 | unless c2.time_sent == s1.time_sent and c2.data == s1.data do 134 | Membrane.Logger.warning( 135 | "Invalid handshake step #{type}: the client's response doesn't match the server payload" 136 | ) 137 | end 138 | 139 | :ok 140 | end 141 | 142 | @doc """ 143 | Returns epoch timestamp of the connection. 144 | """ 145 | @spec epoch(t()) :: non_neg_integer() 146 | def epoch(%__MODULE__{data: <>}), do: epoch 147 | 148 | defp decompose(<>) do 149 | %{time_sent: time_sent, time_read: time_read, data: data} 150 | end 151 | end 152 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/header.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Header do 2 | @moduledoc false 3 | 4 | @enforce_keys [:chunk_stream_id, :type_id] 5 | defstruct @enforce_keys ++ 6 | [ 7 | body_size: 0, 8 | timestamp: 0, 9 | timestamp_delta: 0, 10 | extended_timestamp?: false, 11 | stream_id: 0 12 | ] 13 | 14 | @typedoc """ 15 | RTMP header structure. 16 | 17 | Fields: 18 | * `chunk_stream_id` - chunk stream identifier that the following packet body belongs to 19 | * `timestmap` - chunk timestamp, equals 0 when the header is a part of non-media message 20 | * `body_size` - the size in bytes of the following body payload 21 | * `type_id` - the type of the body payload, for more details please refer to the RTMP docs 22 | * `stream_id` - stream identifier that the message belongs to 23 | """ 24 | @type t :: %__MODULE__{ 25 | chunk_stream_id: integer(), 26 | timestamp: integer(), 27 | body_size: integer(), 28 | type_id: integer(), 29 | stream_id: integer() 30 | } 31 | 32 | defmacro type(:set_chunk_size), do: 0x01 33 | defmacro type(:acknowledgement), do: 0x03 34 | defmacro type(:user_control_message), do: 0x04 35 | defmacro type(:window_acknowledgement_size), do: 0x05 36 | defmacro type(:set_peer_bandwidth), do: 0x06 37 | defmacro type(:audio_message), do: 0x08 38 | defmacro type(:video_message), do: 0x09 39 | defmacro type(:amf_data), do: 0x12 40 | defmacro type(:amf_command), do: 0x14 41 | 42 | @header_type_0 <<0x0::2>> 43 | @header_type_1 <<0x1::2>> 44 | @header_type_2 <<0x2::2>> 45 | @header_type_3 <<0x3::2>> 46 | 47 | @extended_timestamp_marker 0xFFFFFF 48 | 49 | @spec new(Keyword.t()) :: t() 50 | def new(opts) do 51 | struct!(__MODULE__, opts) 52 | end 53 | 54 | @doc """ 55 | Deserializes given binary into an RTMP header structure. 56 | 57 | RTMP headers can be self contained or may depend on preceding headers. 58 | It depends on the first 2 bits of the header: 59 | * `0b00` - current header is self contained and contains all the header information, see `t:t/0` 60 | * `0b01` - current header derives the `stream_id` from the previous header 61 | * `0b10` - same as above plus derives `type_id` and `body_size` 62 | * `0b11` - all values are derived from the previous header with the same `chunk_stream_id` 63 | """ 64 | @spec deserialize(binary(), t() | nil) :: {t(), rest :: binary()} | {:error, :need_more_data} 65 | def deserialize(binary, previous_headers \\ nil) 66 | 67 | # only the deserialization of the 0b00 type can have `nil` previous header 68 | def deserialize( 69 | <<@header_type_0::bitstring, chunk_stream_id::6, timestamp::24, body_size::24, type_id::8, 70 | stream_id::little-integer-size(32), rest::binary>>, 71 | _previous_headers 72 | ) do 73 | with {timestamp, extended_timestamp?, rest} <- extract_timestamp(rest, timestamp) do 74 | header = %__MODULE__{ 75 | chunk_stream_id: chunk_stream_id, 76 | timestamp: timestamp, 77 | extended_timestamp?: extended_timestamp?, 78 | body_size: body_size, 79 | type_id: type_id, 80 | stream_id: stream_id 81 | } 82 | 83 | {header, rest} 84 | end 85 | end 86 | 87 | def deserialize( 88 | <<@header_type_1::bitstring, chunk_stream_id::6, timestamp_delta::24, body_size::24, 89 | type_id::8, rest::binary>>, 90 | previous_headers 91 | ) do 92 | with {timestamp_delta, extended_timestamp?, rest} <- extract_timestamp(rest, timestamp_delta) do 93 | header = %__MODULE__{ 94 | chunk_stream_id: chunk_stream_id, 95 | timestamp: previous_headers[chunk_stream_id].timestamp + timestamp_delta, 96 | timestamp_delta: timestamp_delta, 97 | extended_timestamp?: extended_timestamp?, 98 | body_size: body_size, 99 | type_id: type_id, 100 | stream_id: previous_headers[chunk_stream_id].stream_id 101 | } 102 | 103 | {header, rest} 104 | end 105 | end 106 | 107 | def deserialize( 108 | <<@header_type_2::bitstring, chunk_stream_id::6, timestamp_delta::24, rest::binary>>, 109 | previous_headers 110 | ) do 111 | with {timestamp_delta, extended_timestamp?, rest} <- extract_timestamp(rest, timestamp_delta) do 112 | header = %__MODULE__{ 113 | chunk_stream_id: chunk_stream_id, 114 | timestamp: previous_headers[chunk_stream_id].timestamp + timestamp_delta, 115 | timestamp_delta: timestamp_delta, 116 | extended_timestamp?: extended_timestamp?, 117 | body_size: previous_headers[chunk_stream_id].body_size, 118 | type_id: previous_headers[chunk_stream_id].type_id, 119 | stream_id: previous_headers[chunk_stream_id].stream_id 120 | } 121 | 122 | {header, rest} 123 | end 124 | end 125 | 126 | def deserialize( 127 | <<@header_type_3::bitstring, chunk_stream_id::6, rest::binary>>, 128 | previous_headers 129 | ) do 130 | previous_header = previous_headers[chunk_stream_id] 131 | 132 | if previous_header.extended_timestamp? do 133 | with {timestamp_delta, _extended_timestamp?, rest} <- 134 | extract_timestamp(rest, @extended_timestamp_marker) do 135 | header = %__MODULE__{ 136 | previous_header 137 | | timestamp: previous_header.timestamp + timestamp_delta, 138 | timestamp_delta: timestamp_delta 139 | } 140 | 141 | {header, rest} 142 | end 143 | else 144 | header = %__MODULE__{ 145 | previous_header 146 | | timestamp: previous_header.timestamp + previous_header.timestamp_delta 147 | } 148 | 149 | {header, rest} 150 | end 151 | end 152 | 153 | def deserialize( 154 | <<@header_type_0::bitstring, _chunk_stream_id::6, _rest::binary>>, 155 | _prev_header 156 | ), 157 | do: {:error, :need_more_data} 158 | 159 | def deserialize( 160 | <<@header_type_1::bitstring, _chunk_stream_id::6, _rest::binary>>, 161 | _prev_header 162 | ), 163 | do: {:error, :need_more_data} 164 | 165 | def deserialize( 166 | <<@header_type_2::bitstring, _chunk_stream_id::6, _rest::binary>>, 167 | _prev_header 168 | ), 169 | do: {:error, :need_more_data} 170 | 171 | @spec serialize(t()) :: binary() 172 | def serialize(%__MODULE__{} = header) do 173 | %{ 174 | chunk_stream_id: chunk_stream_id, 175 | timestamp: timestamp, 176 | body_size: body_size, 177 | type_id: type_id, 178 | stream_id: stream_id 179 | } = header 180 | 181 | <<@header_type_0::bitstring, chunk_stream_id::6, timestamp::24, body_size::24, type_id::8, 182 | stream_id::little-integer-size(32)>> 183 | end 184 | 185 | defp extract_timestamp(<>, @extended_timestamp_marker), 186 | do: {timestamp, true, rest} 187 | 188 | defp extract_timestamp(_rest, @extended_timestamp_marker), 189 | do: {:error, :need_more_data} 190 | 191 | defp extract_timestamp(rest, timestamp), 192 | do: {timestamp, false, rest} 193 | end 194 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/message.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Message do 2 | @moduledoc false 3 | 4 | require Membrane.RTMP.Header 5 | 6 | alias Membrane.RTMP.{Header, Messages} 7 | 8 | @type message_data_t :: map() | number() | String.t() | :null 9 | 10 | @type t :: struct() 11 | 12 | @doc """ 13 | Deserializes message binary to a proper struct. 14 | """ 15 | @callback deserialize(value :: binary()) :: t() 16 | 17 | @doc """ 18 | Create message from arguments list. When the message is a AMF command then 19 | the first argument is a command name and the second a sequence number. 20 | """ 21 | @callback from_data([message_data_t()]) :: t() 22 | 23 | @optional_callbacks deserialize: 1, from_data: 1 24 | 25 | @amf_command_to_module %{ 26 | "connect" => Messages.Connect, 27 | "releaseStream" => Messages.ReleaseStream, 28 | "FCPublish" => Messages.FCPublish, 29 | "createStream" => Messages.CreateStream, 30 | "publish" => Messages.Publish, 31 | "@setDataFrame" => Messages.SetDataFrame, 32 | "onMetaData" => Messages.OnMetaData, 33 | "deleteStream" => Messages.DeleteStream 34 | } 35 | 36 | @amf_data_to_module %{ 37 | "@setDataFrame" => Messages.SetDataFrame, 38 | "onMetaData" => Messages.OnMetaData, 39 | "additionalMedia" => Messages.AdditionalMedia 40 | } 41 | 42 | @spec deserialize_message(type_id :: integer(), binary()) :: struct() 43 | def deserialize_message(Header.type(:set_chunk_size), payload), 44 | do: Messages.SetChunkSize.deserialize(payload) 45 | 46 | def deserialize_message(Header.type(:acknowledgement), payload), 47 | do: Messages.Acknowledgement.deserialize(payload) 48 | 49 | def deserialize_message(Header.type(:user_control_message), payload), 50 | do: Messages.UserControl.deserialize(payload) 51 | 52 | def deserialize_message(Header.type(:window_acknowledgement_size), payload), 53 | do: Messages.WindowAcknowledgement.deserialize(payload) 54 | 55 | def deserialize_message(Header.type(:set_peer_bandwidth), payload), 56 | do: Messages.SetPeerBandwidth.deserialize(payload) 57 | 58 | def deserialize_message(Header.type(:amf_data), payload), 59 | do: message_from_modules(payload, @amf_data_to_module) 60 | 61 | def deserialize_message(Header.type(:amf_command), payload), 62 | do: message_from_modules(payload, @amf_command_to_module) 63 | 64 | def deserialize_message(Header.type(:audio_message), payload), 65 | do: Messages.Audio.deserialize(payload) 66 | 67 | def deserialize_message(Header.type(:video_message), payload), 68 | do: Messages.Video.deserialize(payload) 69 | 70 | @spec chunk_payload(binary(), non_neg_integer(), non_neg_integer(), iolist()) :: iolist() 71 | def chunk_payload(payload, chunk_stream_id, chunk_size, acc \\ []) do 72 | case {payload, acc} do 73 | {<>, []} -> 74 | chunk_payload(rest, chunk_stream_id, chunk_size, [chunk]) 75 | 76 | {<>, acc} -> 77 | chunk_payload(rest, chunk_stream_id, chunk_size, [ 78 | acc, 79 | chunk_separator(chunk_stream_id), 80 | chunk 81 | ]) 82 | 83 | {payload, []} -> 84 | [payload] 85 | 86 | {payload, acc} -> 87 | [acc, chunk_separator(chunk_stream_id), payload] 88 | end 89 | end 90 | 91 | defp message_from_modules(payload, mapping, required? \\ false) do 92 | payload 93 | |> Membrane.RTMP.AMF0.Parser.parse() 94 | |> then(fn [command | _rest] = arguments -> 95 | if required? do 96 | Map.fetch!(mapping, command) 97 | else 98 | Map.get(mapping, command, Messages.Anonymous) 99 | end 100 | |> apply(:from_data, [arguments]) 101 | end) 102 | end 103 | 104 | @compile {:inline, chunk_separator: 1} 105 | defp chunk_separator(chunk_stream_id), do: <<0b11::2, chunk_stream_id::6>> 106 | end 107 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/message_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.MessageHandler do 2 | @moduledoc false 3 | 4 | # Module responsible for processing the RTMP messages 5 | # Appropriate responses are sent to the messages received during the initialization phase 6 | # The data received in video and audio is forwarded to the outputs 7 | 8 | require Membrane.Logger, as: Logger 9 | 10 | alias Membrane.RTMP.{ 11 | Handshake, 12 | Header, 13 | Message, 14 | Messages, 15 | Responses 16 | } 17 | 18 | alias Membrane.RTMP.Messages.Serializer 19 | 20 | # maxed out signed int32, we don't have acknowledgements implemented 21 | @window_acknowledgement_size 2_147_483_647 22 | @peer_bandwidth_size 2_147_483_647 23 | 24 | # just to not waste time on chunking 25 | @server_chunk_size 4096 26 | 27 | @type event() :: 28 | {:set_chunk_size_required, non_neg_integer()} 29 | | {:connected, Membrane.RTMP.Messages.Connect.t()} 30 | | {:published, Membrane.RTMP.Messages.Publish.t()} 31 | | :delete_stream 32 | | {:data_available, binary()} 33 | @type t() :: %{ 34 | socket: :gen_tcp.socket() | :ssl.socket(), 35 | socket_module: :gen_tcp | :ssl, 36 | header_sent?: boolean(), 37 | events: [event()], 38 | receiver_pid: pid() | nil, 39 | socket_retries: pos_integer(), 40 | epoch: non_neg_integer(), 41 | publish_msg: Messages.Publish.t() | nil, 42 | publish_header: Header.t() | nil 43 | } 44 | 45 | @spec init(opts :: %{socket: :gen_tcp.socket() | :ssl.socket(), use_ssl?: boolean()}) :: t() 46 | def init(opts) do 47 | %{ 48 | socket: opts.socket, 49 | socket_module: if(opts.use_ssl?, do: :ssl, else: :gen_tcp), 50 | header_sent?: false, 51 | events: [], 52 | receiver_pid: nil, 53 | publish_msg: nil, 54 | publish_header: nil, 55 | # how many times the Source tries to get control of the socket 56 | socket_retries: 3, 57 | # epoch required for performing a handshake with the pipeline 58 | epoch: 0 59 | } 60 | end 61 | 62 | @spec handle_client_messages(list(), map()) :: {map(), list()} 63 | def handle_client_messages([], state) do 64 | {%{state | events: []}, state.events} 65 | end 66 | 67 | def handle_client_messages(messages, state) do 68 | messages 69 | |> Enum.reduce_while(state, fn {header, message}, acc -> 70 | do_handle_client_message(message, header, acc) 71 | end) 72 | |> then(fn state -> 73 | {%{state | events: []}, Enum.reverse(state.events)} 74 | end) 75 | end 76 | 77 | @spec send_publish_success(map()) :: {map(), list()} 78 | def send_publish_success(state) do 79 | Responses.publish_success(state.publish_msg.stream_key) 80 | |> send_rtmp_payload(state.socket, 81 | chunk_stream_id: 3, 82 | stream_id: state.publish_header.stream_id 83 | ) 84 | 85 | {%{state | events: []}, [{:published, state.publish_msg} | state.events]} 86 | end 87 | 88 | # Expected flow of messages: 89 | # 1. [in] c0_c1 handshake -> [out] s0_s1_s2 handshake 90 | # 2. [in] c2 handshake -> [out] empty 91 | # 3. [in] set chunk size -> [out] empty 92 | # 4. [in] connect -> [out] set peer bandwidth, window acknowledgement, stream begin 0, set chunk size, connect _result, onBWDone 93 | # 5. [in] release stream -> [out] _result 94 | # 6. [in] FC publish, create stream -> [out] onFCPublish, _result 95 | # 8. [in] publish -> [out] stream begin 1, onStatus publish 96 | # 9. [in] setDataFrame, media data -> [out] empty 97 | # 10. CONNECTED 98 | 99 | defp do_handle_client_message(%module{data: data}, header, state) 100 | when module in [Messages.Audio, Messages.Video] do 101 | state = get_media_events(header, data, state) 102 | {:cont, state} 103 | end 104 | 105 | defp do_handle_client_message(%Messages.AdditionalMedia{} = media, header, state) do 106 | state = get_additional_media_events(header, media, state) 107 | {:cont, state} 108 | end 109 | 110 | defp do_handle_client_message(%Handshake.Step{type: :s0_s1_s2} = step, _header, state) do 111 | state.socket_module.send(state.socket, Handshake.Step.serialize(step)) 112 | 113 | connection_epoch = Handshake.Step.epoch(step) 114 | {:cont, %{state | epoch: connection_epoch}} 115 | end 116 | 117 | defp do_handle_client_message(%Messages.SetChunkSize{chunk_size: chunk_size}, _header, state) do 118 | {:cont, %{state | events: [{:set_chunk_size_required, chunk_size} | state.events]}} 119 | end 120 | 121 | @stream_begin_type 0 122 | defp do_handle_client_message(%Messages.Connect{} = connect_msg, _header, state) do 123 | [ 124 | %Messages.WindowAcknowledgement{size: @window_acknowledgement_size}, 125 | %Messages.SetPeerBandwidth{size: @peer_bandwidth_size}, 126 | # stream begin type 127 | %Messages.UserControl{event_type: @stream_begin_type, data: <<0, 0, 0, 0>>}, 128 | # the chunk size is independent for both sides 129 | %Messages.SetChunkSize{chunk_size: @server_chunk_size} 130 | ] 131 | |> Enum.each(&send_rtmp_payload(&1, state.socket, chunk_stream_id: 2)) 132 | 133 | Responses.connection_success() 134 | |> send_rtmp_payload(state.socket, chunk_stream_id: 3) 135 | 136 | Responses.on_bw_done() 137 | |> send_rtmp_payload(state.socket, chunk_stream_id: 3) 138 | 139 | state = %{state | events: [{:connected, connect_msg} | state.events]} 140 | {:cont, state} 141 | end 142 | 143 | # According to ffmpeg's documentation, this command should make the server release channel for a media stream 144 | # We are simply acknowledging the message 145 | defp do_handle_client_message(%Messages.ReleaseStream{} = release_stream_msg, _header, state) do 146 | release_stream_msg.tx_id 147 | |> Responses.default_result([0.0, :null]) 148 | |> send_rtmp_payload(state.socket, chunk_stream_id: 3) 149 | 150 | {:cont, state} 151 | end 152 | 153 | defp do_handle_client_message(%Messages.Publish{} = publish_msg, %Header{} = header, state) do 154 | %Messages.UserControl{event_type: @stream_begin_type, data: <<0, 0, 0, 1>>} 155 | |> send_rtmp_payload(state.socket, chunk_stream_id: 2) 156 | 157 | # at this point pause the unfinished handshake until pipeline demands data from this client 158 | # (this mechanism prevents accepting streams with no listeners) 159 | 160 | {:halt, %{state | publish_msg: publish_msg, publish_header: header}} 161 | end 162 | 163 | # A message containing stream metadata 164 | defp do_handle_client_message(%Messages.SetDataFrame{} = _data_frame, _header, state) do 165 | {:cont, state} 166 | end 167 | 168 | defp do_handle_client_message(%Messages.OnMetaData{} = _on_meta_data, _header, state) do 169 | {:cont, state} 170 | end 171 | 172 | # According to ffmpeg's documentation, this command should prepare the server to receive media streams 173 | # We are simply acknowledging the message 174 | defp do_handle_client_message(%Messages.FCPublish{}, _header, state) do 175 | %Messages.Anonymous{name: "onFCPublish", properties: []} 176 | |> send_rtmp_payload(state.socket, chunk_stream_id: 3) 177 | 178 | {:cont, state} 179 | end 180 | 181 | defp do_handle_client_message(%Messages.CreateStream{} = create_stream, _header, state) do 182 | # following ffmpeg rtmp server implementation 183 | stream_id = 1.0 184 | 185 | create_stream.tx_id 186 | |> Responses.default_result([:null, stream_id]) 187 | |> send_rtmp_payload(state.socket, chunk_stream_id: 3) 188 | 189 | {:cont, state} 190 | end 191 | 192 | # we ignore acknowledgement messages, but they're rarely used anyways 193 | defp do_handle_client_message(%module{}, _header, state) 194 | when module in [Messages.Acknowledgement, Messages.WindowAcknowledgement] do 195 | Logger.debug("#{inspect(module)} received, ignoring as acknowledgements are not implemented") 196 | 197 | {:cont, state} 198 | end 199 | 200 | @ping_request_type 6 201 | @ping_response_type 7 202 | # according to the spec this should be sent by server, but some clients send it anyway (restream.io) 203 | defp do_handle_client_message( 204 | %Messages.UserControl{event_type: @ping_request_type} = ping_request, 205 | _header, 206 | state 207 | ) do 208 | %Messages.UserControl{event_type: @ping_response_type, data: ping_request.data} 209 | |> send_rtmp_payload(state.socket, chunk_stream_id: 2) 210 | 211 | {:cont, state} 212 | end 213 | 214 | defp do_handle_client_message(%Messages.UserControl{} = msg, _header, state) do 215 | Logger.warning("Received unsupported user control message of type #{inspect(msg.event_type)}") 216 | 217 | {:cont, state} 218 | end 219 | 220 | defp do_handle_client_message(%Messages.DeleteStream{}, _header, state) do 221 | {:halt, %{state | events: [:delete_stream | state.events]}} 222 | end 223 | 224 | # Check bandwidth message 225 | defp do_handle_client_message(%Messages.Anonymous{name: "_checkbw"} = msg, _header, state) do 226 | # the message doesn't belong to spec, let's just follow this implementation 227 | # https://github.com/use-go/wsa/blob/b4d0808fe5b6daff1c381d5127bbd450168230a1/rtmp/RTMP.go#L1014 228 | msg.tx_id 229 | |> Responses.default_result([:null, 0.0]) 230 | |> send_rtmp_payload(state.socket, chunk_stream_id: 3) 231 | 232 | {:cont, state} 233 | end 234 | 235 | defp do_handle_client_message(%Messages.Anonymous{} = message, _header, state) do 236 | Logger.debug("Unknown message: #{inspect(message)}") 237 | 238 | {:cont, state} 239 | end 240 | 241 | defp get_media_events(rtmp_header, data, %{header_sent?: true} = state) do 242 | payload = get_flv_tag(rtmp_header, data) 243 | 244 | Map.update!(state, :events, &[{:data_available, payload} | &1]) 245 | end 246 | 247 | defp get_media_events(rtmp_header, data, state) do 248 | payload = get_flv_header() <> get_flv_tag(rtmp_header, data) 249 | 250 | %{ 251 | state 252 | | header_sent?: true, 253 | events: [{:data_available, payload} | state.events] 254 | } 255 | end 256 | 257 | defp get_additional_media_events(rtmp_header, additional_media, %{header_sent?: true} = state) do 258 | # NOTE: we are replacing the type_id from 18 to 8 (script data to audio data) as it carries the 259 | # additional audio track 260 | data = additional_media.media 261 | 262 | header = %Membrane.RTMP.Header{ 263 | rtmp_header 264 | | type_id: 8, 265 | body_size: byte_size(data) 266 | } 267 | 268 | # NOTE: for additional media we are also setting the stream_id to 1. 269 | # It is against the spec but it simplifies things for us since we don't have to 270 | # handle dynamic pads in the RTMP source + our FLV demuxer handles that well. 271 | payload = get_flv_tag(header, 1, data) 272 | 273 | event = {:data_available, payload} 274 | 275 | Map.update!(state, :events, &[event | &1]) 276 | end 277 | 278 | defp get_additional_media_events(rtmp_header, additional_media, state) do 279 | data = additional_media.media 280 | 281 | header = %Membrane.RTMP.Header{rtmp_header | type_id: 8, body_size: byte_size(data)} 282 | payload = get_flv_header() <> get_flv_tag(header, 1, data) 283 | 284 | event = {:data_available, payload} 285 | 286 | %{state | header_sent?: true, events: [event | state.event]} 287 | end 288 | 289 | defp get_flv_header() do 290 | alias Membrane.FLV 291 | 292 | {header, 0} = 293 | FLV.Serializer.serialize( 294 | %FLV.Header{audio_present?: true, video_present?: true}, 295 | 0 296 | ) 297 | 298 | # Add PreviousTagSize, which is 0 for the first tag 299 | header <> <<0::32>> 300 | end 301 | 302 | # according to the FLV spec, the stream ID should always be 0 303 | # but we can use 1 for hacking around Twitch's addtional audio stream 304 | defp get_flv_tag( 305 | %Membrane.RTMP.Header{ 306 | timestamp: timestamp, 307 | body_size: data_size, 308 | type_id: type_id 309 | }, 310 | stream_id \\ 0, 311 | payload 312 | ) do 313 | tag_size = data_size + 11 314 | 315 | <> = <> 316 | 317 | <> 319 | end 320 | 321 | defp send_rtmp_payload(message, socket, opts) do 322 | type = Serializer.type(message) 323 | body = Serializer.serialize(message) 324 | 325 | chunk_stream_id = Keyword.get(opts, :chunk_stream_id, 2) 326 | 327 | header = 328 | [chunk_stream_id: chunk_stream_id, type_id: type, body_size: byte_size(body)] 329 | |> Keyword.merge(opts) 330 | |> Header.new() 331 | |> Header.serialize() 332 | 333 | payload = Message.chunk_payload(body, chunk_stream_id, @server_chunk_size) 334 | 335 | socket_module(socket).send(socket, [header | payload]) 336 | end 337 | 338 | @compile {:inline, socket_module: 1} 339 | defp socket_module({:sslsocket, _1, _2}), do: :ssl 340 | defp socket_module(_other), do: :gen_tcp 341 | end 342 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/message_parser.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.MessageParser do 2 | @moduledoc false 3 | 4 | require Membrane.Logger 5 | 6 | alias Membrane.RTMP.{Handshake, Header, Message, Messages} 7 | 8 | @enforce_keys [:state_machine, :buffer, :chunk_size, :handshake] 9 | defstruct @enforce_keys ++ [previous_headers: %{}, current_tx_id: 1] 10 | 11 | @type state_machine_t :: 12 | :handshake | :connecting | :connected 13 | 14 | @type packet_t :: binary() 15 | 16 | @type t :: %__MODULE__{ 17 | state_machine: state_machine_t(), 18 | buffer: binary(), 19 | previous_headers: map(), 20 | # the chunk size of incoming messages (the other side of connection) 21 | chunk_size: non_neg_integer(), 22 | current_tx_id: non_neg_integer(), 23 | handshake: Handshake.State.t() 24 | } 25 | 26 | @doc """ 27 | Initializes the RTMP MessageParser. 28 | 29 | The MessageParser starts in a handshake process which is dictated by the passed 30 | handshake state. 31 | """ 32 | @spec init(Handshake.State.t(), Keyword.t()) :: t() 33 | def init(handshake, opts \\ []) do 34 | chunk_size = Keyword.get(opts, :chunk_size, 128) 35 | 36 | %__MODULE__{ 37 | state_machine: :handshake, 38 | buffer: <<>>, 39 | # previous header for each of the stream chunks 40 | previous_headers: %{}, 41 | chunk_size: chunk_size, 42 | handshake: handshake 43 | } 44 | end 45 | 46 | @doc """ 47 | Parses RTMP messages from a packet. 48 | 49 | The RTMP connection is based on TCP therefore we are operating on a continuous stream of bytes. 50 | In such case packets received on TCP sockets may contain a partial RTMP packet or several full packets. 51 | 52 | `MessageParser` is already able to request more data if packet is incomplete but it is not aware 53 | if its current buffer contains more than one message, therefore we need to call the `&MessageParser.handle_packet/2` 54 | as long as we decide to receive more messages (before starting to relay media packets). 55 | 56 | Once we hit `:need_more_data` the function returns the list of parsed messages and the message_parser then is ready 57 | to receive more data to continue with emitting new messages. 58 | """ 59 | @spec parse_packet_messages(packet :: binary(), message_parser :: struct(), [{any(), any()}]) :: 60 | {[Message.t()], message_parser :: struct()} 61 | def parse_packet_messages(packet, message_parser, messages \\ []) 62 | 63 | def parse_packet_messages(<<>>, %{buffer: <<>>} = message_parser, messages) do 64 | {Enum.reverse(messages), message_parser} 65 | end 66 | 67 | def parse_packet_messages(packet, message_parser, messages) do 68 | case handle_packet(packet, message_parser) do 69 | {header, message, message_parser} -> 70 | parse_packet_messages(<<>>, message_parser, [{header, message} | messages]) 71 | 72 | {:need_more_data, message_parser} -> 73 | {Enum.reverse(messages), message_parser} 74 | 75 | {:handshake_done, message_parser} -> 76 | parse_packet_messages(<<>>, message_parser, messages) 77 | 78 | {%Handshake.Step{} = step, message_parser} -> 79 | parse_packet_messages(<<>>, message_parser, [{nil, step} | messages]) 80 | end 81 | end 82 | 83 | @doc """ 84 | Generates a list of the following transaction tx_ids. 85 | 86 | Updates the internal transaction id counter so that 87 | the MessageParser can be further used for generating the next ones. 88 | """ 89 | @spec generate_tx_ids(t(), n :: non_neg_integer()) :: {list(non_neg_integer()), t()} 90 | def generate_tx_ids(%__MODULE__{current_tx_id: tx_id} = message_parser, n) when n > 0 do 91 | tx_ids = Enum.to_list(tx_id..(tx_id + n - 1)) 92 | 93 | {tx_ids, %{message_parser | current_tx_id: tx_id + n}} 94 | end 95 | 96 | @spec handle_packet(packet_t(), t()) :: 97 | {Handshake.Step.t() | :need_more_data | :handshake_done | binary(), t()} 98 | | {Header.t(), Message.t(), t()} 99 | def handle_packet(packet, state) 100 | 101 | def handle_packet( 102 | packet, 103 | %{state_machine: :connected, buffer: buffer, chunk_size: chunk_size} = state 104 | ) do 105 | payload = buffer <> packet 106 | 107 | case read_frame(payload, state.previous_headers, chunk_size) do 108 | {:error, :need_more_data} -> 109 | {:need_more_data, %__MODULE__{state | buffer: payload}} 110 | 111 | {header, message, rest} -> 112 | state = update_state_with_message(state, header, message, rest) 113 | 114 | {header, message, state} 115 | end 116 | end 117 | 118 | def handle_packet( 119 | packet, 120 | %{state_machine: :handshake, buffer: buffer, handshake: handshake} = state 121 | ) do 122 | payload = buffer <> packet 123 | 124 | step_size = Handshake.expects_bytes(handshake) 125 | 126 | case payload do 127 | <> -> 128 | case Handshake.handle_step(step_data, handshake) do 129 | {:continue_handshake, step, handshake} -> 130 | # continue with the handshake 131 | {step, %__MODULE__{state | buffer: rest, handshake: handshake}} 132 | 133 | # the handshake is done but with last step to return 134 | {:handshake_finished, step, _handshake} -> 135 | {step, 136 | %__MODULE__{ 137 | state 138 | | buffer: rest, 139 | handshake: nil, 140 | state_machine: fsm_transition(:handshake) 141 | }} 142 | 143 | # the handshake is done without further steps 144 | {:handshake_finished, _handshake} -> 145 | {:handshake_done, 146 | %__MODULE__{ 147 | state 148 | | buffer: rest, 149 | handshake: nil, 150 | state_machine: fsm_transition(:handshake) 151 | }} 152 | 153 | {:error, {:invalid_handshake_step, step_type}} -> 154 | raise "Invalid handshake step: #{step_type}" 155 | end 156 | 157 | _payload -> 158 | {:need_more_data, %__MODULE__{state | buffer: payload}} 159 | end 160 | end 161 | 162 | def handle_packet( 163 | packet, 164 | %{state_machine: :connecting, buffer: buffer, chunk_size: chunk_size} = state 165 | ) do 166 | payload = buffer <> packet 167 | 168 | case read_frame(payload, state.previous_headers, chunk_size) do 169 | {:error, :need_more_data} -> 170 | {:need_more_data, %__MODULE__{state | buffer: payload}} 171 | 172 | {header, message, rest} -> 173 | state = update_state_with_message(state, header, message, rest) 174 | 175 | {header, message, state} 176 | end 177 | end 178 | 179 | defp read_frame(packet, previous_headers, chunk_size) do 180 | case Header.deserialize(packet, previous_headers) do 181 | {%Header{} = header, rest} -> 182 | chunked_body_size = calculate_chunked_body_size(header, chunk_size) 183 | 184 | case rest do 185 | <> -> 186 | combined_body = combine_body_chunks(body, chunk_size, header) 187 | 188 | message = Message.deserialize_message(header.type_id, combined_body) 189 | 190 | {header, message, rest} 191 | 192 | _rest -> 193 | {:error, :need_more_data} 194 | end 195 | 196 | {:error, :need_more_data} = error -> 197 | error 198 | end 199 | end 200 | 201 | defp calculate_chunked_body_size(%Header{body_size: body_size} = header, chunk_size) do 202 | if body_size > chunk_size do 203 | # if a message's body is greater than the chunk size then 204 | # after every chunk_size's bytes there is a 0x03 one byte header that 205 | # needs to be stripped and is not counted into the body_size 206 | headers_to_strip = div(body_size - 1, chunk_size) 207 | 208 | # if the initial header contains a extended timestamp then 209 | # every following chunk will contain the timestamp 210 | timestamps_to_strip = if header.extended_timestamp?, do: headers_to_strip * 4, else: 0 211 | 212 | body_size + headers_to_strip + timestamps_to_strip 213 | else 214 | body_size 215 | end 216 | end 217 | 218 | # message's size can exceed the defined chunk size 219 | # in this case the message gets divided into 220 | # a sequence of smaller packets separated by the a header type 3 byte 221 | # (the first 2 bits has to be 0b11) 222 | defp combine_body_chunks(body, chunk_size, header) do 223 | if byte_size(body) <= chunk_size do 224 | body 225 | else 226 | do_combine_body_chunks(body, chunk_size, header, []) 227 | end 228 | end 229 | 230 | defp do_combine_body_chunks(body, chunk_size, header, acc) do 231 | case body do 232 | <> 233 | when header.extended_timestamp? and timestamp == header.timestamp -> 234 | do_combine_body_chunks(rest, chunk_size, header, [acc, body]) 235 | 236 | # cut out the header byte (staring with 0b11) 237 | <> -> 238 | do_combine_body_chunks(rest, chunk_size, header, [acc, body]) 239 | 240 | <<_body::binary-size(chunk_size), header_type::2, _chunk_stream_id::6, _rest::binary>> -> 241 | Membrane.Logger.warning( 242 | "Unexpected header type when combining body chunks: #{header_type}" 243 | ) 244 | 245 | IO.iodata_to_binary([acc, body]) 246 | 247 | body -> 248 | IO.iodata_to_binary([acc, body]) 249 | end 250 | end 251 | 252 | # in case of client interception the Publish message indicates successful connection 253 | # (unless proxy temrinates the connection) and medai can be relayed 254 | defp message_fsm_transition(%Messages.Publish{}), do: :connected 255 | 256 | # when receiving audio or video messages, we are remaining in connected state 257 | defp message_fsm_transition(%Messages.Audio{}), do: :connected 258 | defp message_fsm_transition(%Messages.Video{}), do: :connected 259 | 260 | # in case of server interception the `NetStream.Publish.Start` indicates 261 | # that the connection has been successful and media can be relayed 262 | defp message_fsm_transition(%Messages.Anonymous{ 263 | name: "onStatus", 264 | properties: [:null, %{"code" => "NetStream.Publish.Start"}] 265 | }), 266 | do: :connected 267 | 268 | defp message_fsm_transition(_message), do: :connecting 269 | 270 | defp fsm_transition(:handshake), do: :connecting 271 | 272 | defp update_state_with_message(state, header, message, rest) do 273 | updated_headers = Map.put(state.previous_headers, header.chunk_stream_id, header) 274 | 275 | %__MODULE__{ 276 | state 277 | | chunk_size: maybe_update_chunk_size(message, state), 278 | previous_headers: updated_headers, 279 | buffer: rest, 280 | state_machine: message_fsm_transition(message) 281 | } 282 | end 283 | 284 | defp maybe_update_chunk_size(%Messages.SetChunkSize{chunk_size: size}, _state), do: size 285 | defp maybe_update_chunk_size(_size, %{chunk_size: size}), do: size 286 | end 287 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/acknowledgement.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.Acknowledgement do 2 | @moduledoc false 3 | 4 | @behaviour Membrane.RTMP.Message 5 | 6 | @enforce_keys [:sequence_number] 7 | defstruct @enforce_keys 8 | 9 | @type t :: %__MODULE__{ 10 | sequence_number: non_neg_integer() 11 | } 12 | 13 | @impl true 14 | def deserialize(<>) do 15 | %__MODULE__{sequence_number: sequence_number} 16 | end 17 | 18 | defimpl Membrane.RTMP.Messages.Serializer do 19 | require Membrane.RTMP.Header 20 | 21 | @impl true 22 | def serialize(%@for{sequence_number: sequence_number}) do 23 | <> 24 | end 25 | 26 | @impl true 27 | def type(%@for{}), do: Membrane.RTMP.Header.type(:acknowledgement) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/additional_media.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.AdditionalMedia do 2 | @moduledoc false 3 | 4 | @behaviour Membrane.RTMP.Message 5 | 6 | alias Membrane.RTMP.AMF0.Encoder 7 | 8 | @enforce_keys [:id, :media] 9 | defstruct @enforce_keys 10 | 11 | @type t :: %__MODULE__{ 12 | id: String.t(), 13 | media: binary() 14 | } 15 | 16 | @impl true 17 | def from_data(["additionalMedia", %{"id" => id, "media" => media}]) do 18 | %__MODULE__{id: id, media: media} 19 | end 20 | 21 | @doc false 22 | @spec to_map(t()) :: map() 23 | def to_map(%__MODULE__{id: id, media: media}) do 24 | # TODO: this media should be AMF3 encoded 25 | %{"id" => id, "media" => media} 26 | end 27 | 28 | defimpl Membrane.RTMP.Messages.Serializer do 29 | require Membrane.RTMP.Header 30 | 31 | @impl true 32 | def serialize(%@for{} = message) do 33 | Encoder.encode([@for.to_map(message)]) 34 | end 35 | 36 | @impl true 37 | def type(%@for{}), do: Membrane.RTMP.Header.type(:amf_data) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/anonymous.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.Anonymous do 2 | @moduledoc """ 3 | A catch-all module for all the messages that don't have a dedicated module, eg. `onBWDone`, `onStatus`, `_result`. 4 | """ 5 | 6 | @behaviour Membrane.RTMP.Message 7 | 8 | @enforce_keys [:name, :properties] 9 | defstruct [tx_id: nil] ++ @enforce_keys 10 | 11 | @type t :: %__MODULE__{ 12 | name: String.t(), 13 | properties: any(), 14 | tx_id: non_neg_integer() | nil 15 | } 16 | 17 | @impl true 18 | def from_data([name, tx_id | properties]) when is_binary(name) do 19 | %__MODULE__{name: name, tx_id: tx_id, properties: properties} 20 | end 21 | 22 | def from_data([name]) when is_binary(name) do 23 | %__MODULE__{name: name, tx_id: nil, properties: []} 24 | end 25 | 26 | defimpl Membrane.RTMP.Messages.Serializer do 27 | require Membrane.RTMP.Header 28 | 29 | alias Membrane.RTMP.AMF0.Encoder 30 | 31 | @impl true 32 | def serialize(%@for{name: name, tx_id: nil, properties: properties}) do 33 | Encoder.encode([name | properties]) 34 | end 35 | 36 | def serialize(%@for{name: name, tx_id: tx_id, properties: properties}) do 37 | Encoder.encode([name, tx_id | properties]) 38 | end 39 | 40 | @impl true 41 | def type(%@for{}), do: Membrane.RTMP.Header.type(:amf_command) 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/audio.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.Audio do 2 | @moduledoc false 3 | 4 | @behaviour Membrane.RTMP.Message 5 | 6 | @enforce_keys [:data] 7 | defstruct @enforce_keys 8 | 9 | @type t :: %__MODULE__{ 10 | data: binary() 11 | } 12 | 13 | @impl true 14 | def deserialize(<>) do 15 | %__MODULE__{data: data} 16 | end 17 | 18 | defimpl Membrane.RTMP.Messages.Serializer do 19 | require Membrane.RTMP.Header 20 | 21 | @impl true 22 | def serialize(%@for{data: data}) do 23 | data 24 | end 25 | 26 | @impl true 27 | def type(%@for{}), do: Membrane.RTMP.Header.type(:audio_message) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/command/connect.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.Connect do 2 | @moduledoc """ 3 | Defines the RTMP `connect` command. 4 | """ 5 | 6 | @behaviour Membrane.RTMP.Message 7 | 8 | alias Membrane.RTMP.AMF0.Encoder 9 | 10 | @enforce_keys [:app, :tc_url] 11 | defstruct @enforce_keys ++ 12 | [ 13 | :flash_version, 14 | :swf_url, 15 | :fpad, 16 | :audio_codecs, 17 | :video_codecs, 18 | :video_function, 19 | :page_url, 20 | :object_encoding, 21 | extra: %{}, 22 | tx_id: 0 23 | ] 24 | 25 | @type t :: %__MODULE__{ 26 | app: String.t(), 27 | tc_url: String.t(), 28 | flash_version: String.t() | nil, 29 | swf_url: String.t() | nil, 30 | fpad: boolean() | nil, 31 | audio_codecs: float() | nil, 32 | video_codecs: float() | nil, 33 | video_function: float() | nil, 34 | page_url: String.t() | nil, 35 | object_encoding: float() | nil, 36 | extra: %{optional(String.t()) => any()}, 37 | tx_id: float() | non_neg_integer() 38 | } 39 | 40 | @keys_to_attributes %{ 41 | app: "app", 42 | tc_url: "tcUrl", 43 | flash_version: "flashVer", 44 | swf_url: "swfUrl", 45 | fpad: "fpad", 46 | audio_codecs: "audioCodecs", 47 | video_codecs: "videoCodecs", 48 | video_function: "videoFunction", 49 | page_url: "pageUrl", 50 | object_encoding: "objectEncoding" 51 | } 52 | 53 | @attributes_to_keys Map.new(@keys_to_attributes, fn {key, attribute} -> {attribute, key} end) 54 | 55 | @name "connect" 56 | 57 | @impl true 58 | def from_data([@name, tx_id, properties]) do 59 | # We take keys according to RFC, but preserve all extra ones 60 | # https://github.com/melpon/rfc/blob/master/rtmp.md#7211-connect 61 | {rfc, extra} = Map.split(properties, Map.keys(@attributes_to_keys)) 62 | 63 | rfc 64 | |> Map.new(fn {string_key, value} -> {Map.fetch!(@attributes_to_keys, string_key), value} end) 65 | |> Map.merge(%{tx_id: tx_id, extra: extra}) 66 | |> then(&struct!(__MODULE__, &1)) 67 | end 68 | 69 | @spec to_map(t()) :: map() 70 | def to_map(%__MODULE__{} = message) do 71 | message 72 | |> Map.take(Map.keys(@keys_to_attributes)) 73 | |> Map.new(fn {key, value} -> {Map.fetch!(@keys_to_attributes, key), value} end) 74 | |> Map.merge(message.extra) 75 | end 76 | 77 | defimpl Membrane.RTMP.Messages.Serializer do 78 | require Membrane.RTMP.Header 79 | 80 | @impl true 81 | def serialize(%@for{} = msg) do 82 | msg 83 | |> @for.to_map() 84 | |> then(&Encoder.encode(["connect", msg.tx_id, &1])) 85 | end 86 | 87 | @impl true 88 | def type(%@for{}), do: Membrane.RTMP.Header.type(:amf_command) 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/command/create_stream.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.CreateStream do 2 | @moduledoc false 3 | 4 | @behaviour Membrane.RTMP.Message 5 | 6 | alias Membrane.RTMP.AMF0.Encoder 7 | 8 | defstruct tx_id: 0 9 | 10 | @type t :: %__MODULE__{ 11 | tx_id: non_neg_integer() 12 | } 13 | 14 | @name "createStream" 15 | 16 | @impl true 17 | def from_data([@name, tx_id, :null]) do 18 | %__MODULE__{tx_id: tx_id} 19 | end 20 | 21 | defimpl Membrane.RTMP.Messages.Serializer do 22 | require Membrane.RTMP.Header 23 | 24 | @impl true 25 | def serialize(%@for{tx_id: tx_id}) do 26 | Encoder.encode(["createStream", tx_id, :null]) 27 | end 28 | 29 | @impl true 30 | def type(%@for{}), do: Membrane.RTMP.Header.type(:amf_command) 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/command/delete_stream.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.DeleteStream do 2 | @moduledoc false 3 | 4 | @behaviour Membrane.RTMP.Message 5 | 6 | alias Membrane.RTMP.AMF0.Encoder 7 | 8 | @enforce_keys [:stream_id] 9 | 10 | defstruct @enforce_keys ++ [tx_id: 0] 11 | 12 | @type t :: %__MODULE__{ 13 | tx_id: non_neg_integer(), 14 | stream_id: non_neg_integer() 15 | } 16 | 17 | @name "deleteStream" 18 | 19 | @impl true 20 | def from_data([@name, tx_id, :null, stream_id]) do 21 | %__MODULE__{tx_id: tx_id, stream_id: stream_id} 22 | end 23 | 24 | defimpl Membrane.RTMP.Messages.Serializer do 25 | require Membrane.RTMP.Header 26 | 27 | @impl true 28 | def serialize(%@for{tx_id: tx_id, stream_id: stream_id}) do 29 | Encoder.encode(["deleteStream", tx_id, :null, stream_id]) 30 | end 31 | 32 | @impl true 33 | def type(%@for{}), do: Membrane.RTMP.Header.type(:amf_command) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/command/fc_publish.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.FCPublish do 2 | @moduledoc false 3 | 4 | @behaviour Membrane.RTMP.Message 5 | 6 | alias Membrane.RTMP.AMF0.Encoder 7 | 8 | @enforce_keys [:stream_key] 9 | defstruct [tx_id: 0] ++ @enforce_keys 10 | 11 | @type t :: %__MODULE__{ 12 | stream_key: String.t(), 13 | tx_id: non_neg_integer() 14 | } 15 | 16 | @name "FCPublish" 17 | 18 | @impl true 19 | def from_data([@name, tx_id, :null, stream_key]) do 20 | %__MODULE__{tx_id: tx_id, stream_key: stream_key} 21 | end 22 | 23 | defimpl Membrane.RTMP.Messages.Serializer do 24 | require Membrane.RTMP.Header 25 | 26 | @impl true 27 | def serialize(%@for{tx_id: tx_id, stream_key: stream_key}) do 28 | Encoder.encode(["FCPublish", tx_id, :null, stream_key]) 29 | end 30 | 31 | @impl true 32 | def type(%@for{}), do: Membrane.RTMP.Header.type(:amf_command) 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/command/publish.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.Publish do 2 | @moduledoc """ 3 | Defines the RTMP `publish` command. 4 | """ 5 | 6 | @behaviour Membrane.RTMP.Message 7 | 8 | alias Membrane.RTMP.AMF0.Encoder 9 | 10 | @enforce_keys [:stream_key] 11 | defstruct [:publish_type, tx_id: 0] ++ @enforce_keys 12 | 13 | @type t :: %__MODULE__{ 14 | stream_key: String.t(), 15 | # NOTE: some RTMP clients like restream.io omit the publishing type 16 | publish_type: String.t() | nil, 17 | tx_id: non_neg_integer() 18 | } 19 | 20 | @name "publish" 21 | 22 | @impl true 23 | def from_data([@name, tx_id, :null, stream_key, publish_type]) do 24 | %__MODULE__{tx_id: tx_id, stream_key: stream_key, publish_type: publish_type} 25 | end 26 | 27 | def from_data([@name, tx_id, :null, stream_key]) do 28 | %__MODULE__{tx_id: tx_id, stream_key: stream_key} 29 | end 30 | 31 | defimpl Membrane.RTMP.Messages.Serializer do 32 | require Membrane.RTMP.Header 33 | 34 | @impl true 35 | def serialize(%@for{tx_id: tx_id, stream_key: stream_key} = msg) do 36 | to_encode = 37 | ["publish", tx_id, :null, stream_key] ++ 38 | if(msg.publish_type, do: [msg.publish_type], else: []) 39 | 40 | Encoder.encode(to_encode) 41 | end 42 | 43 | @impl true 44 | def type(%@for{}), do: Membrane.RTMP.Header.type(:amf_command) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/command/release_stream.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.ReleaseStream do 2 | @moduledoc """ 3 | Defines the RTMP `releaseStream` command. 4 | """ 5 | 6 | @behaviour Membrane.RTMP.Message 7 | 8 | alias Membrane.RTMP.AMF0.Encoder 9 | 10 | @enforce_keys [:stream_key] 11 | defstruct [tx_id: 0] ++ @enforce_keys 12 | 13 | @type t :: %__MODULE__{ 14 | stream_key: String.t(), 15 | tx_id: non_neg_integer() 16 | } 17 | 18 | @name "releaseStream" 19 | 20 | @impl true 21 | def from_data([@name, tx_id, :null, stream_key]) do 22 | %__MODULE__{tx_id: tx_id, stream_key: stream_key} 23 | end 24 | 25 | defimpl Membrane.RTMP.Messages.Serializer do 26 | require Membrane.RTMP.Header 27 | 28 | @impl true 29 | def serialize(%@for{tx_id: tx_id, stream_key: stream_key}) do 30 | Encoder.encode(["releaseStream", tx_id, :null, stream_key]) 31 | end 32 | 33 | @impl true 34 | def type(%@for{}), do: Membrane.RTMP.Header.type(:amf_command) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/on_expect_additional_media.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.OnExpectAdditionalMedia do 2 | @moduledoc """ 3 | Defines the RTMP `onExpectAdditionalMedia` command that is related to Twitch's RTMP additional 4 | media track message. 5 | 6 | The command is usually a part of `@setDataFrame` command but for more convencience it is 7 | extracted. 8 | """ 9 | 10 | @behaviour Membrane.RTMP.Message 11 | 12 | alias Membrane.RTMP.AMF0.Encoder 13 | 14 | @attributes_to_keys %{ 15 | "additionalMedia" => :additional_media, 16 | "defaultMedia" => :default_media, 17 | "processingIntents" => :processing_intents 18 | } 19 | 20 | @keys_to_attributes Map.new(@attributes_to_keys, fn {key, value} -> {value, key} end) 21 | 22 | defstruct Map.keys(@keys_to_attributes) 23 | 24 | @type t :: %__MODULE__{ 25 | additional_media: map(), 26 | default_media: map(), 27 | processing_intents: [String.t()] 28 | } 29 | 30 | @impl true 31 | def from_data(["@setDataFrame", "onExpectAdditionalMedia", properties]) do 32 | new(properties) 33 | end 34 | 35 | @spec new([{String.t(), any()}]) :: t() 36 | def new(options) do 37 | params = 38 | options 39 | |> Map.new() 40 | |> Map.take(Map.keys(@attributes_to_keys)) 41 | |> Enum.map(fn {key, value} -> 42 | {Map.fetch!(@attributes_to_keys, key), value} 43 | end) 44 | 45 | struct!(__MODULE__, params) 46 | end 47 | 48 | # helper for message serialization 49 | @doc false 50 | @spec to_map(t()) :: map() 51 | def to_map(%__MODULE__{} = message) do 52 | Map.new(message, fn {key, value} -> {Map.fetch!(@keys_to_attributes, key), value} end) 53 | end 54 | 55 | defimpl Membrane.RTMP.Messages.Serializer do 56 | require Membrane.RTMP.Header 57 | 58 | @impl true 59 | def serialize(%@for{} = message) do 60 | Encoder.encode([@for.to_map(message)]) 61 | end 62 | 63 | @impl true 64 | def type(%@for{}), do: Membrane.RTMP.Header.type(:amf_data) 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/on_meta_data.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.OnMetaData do 2 | @moduledoc """ 3 | Defines the RTMP `onMetaData` command (sent by nginx client). 4 | """ 5 | 6 | @behaviour Membrane.RTMP.Message 7 | 8 | alias Membrane.RTMP.AMF0.Encoder 9 | 10 | @attributes_to_keys %{ 11 | "duration" => :duration, 12 | "width" => :width, 13 | "height" => :height, 14 | "videocodecid" => :video_codec_id, 15 | "videodatarate" => :video_data_rate, 16 | "framerate" => :framerate, 17 | "audiocodecid" => :audio_codec_id, 18 | "audiodatarate" => :audio_data_rate 19 | } 20 | 21 | @keys_to_attributes Map.new(@attributes_to_keys, fn {key, value} -> {value, key} end) 22 | 23 | defstruct Map.keys(@keys_to_attributes) 24 | 25 | @type t :: %__MODULE__{ 26 | duration: number(), 27 | # video related 28 | width: number(), 29 | height: number(), 30 | video_codec_id: number(), 31 | video_data_rate: number(), 32 | framerate: number(), 33 | # audio related 34 | audio_codec_id: number(), 35 | audio_data_rate: number() 36 | } 37 | 38 | @impl true 39 | def from_data(["onMetaData", properties]) do 40 | new(properties) 41 | end 42 | 43 | @spec new([{String.t(), any()}]) :: t() 44 | def new(options) do 45 | params = 46 | options 47 | |> Map.new() 48 | |> Map.take(Map.keys(@attributes_to_keys)) 49 | |> Enum.map(fn {key, value} -> 50 | {Map.fetch!(@attributes_to_keys, key), value} 51 | end) 52 | 53 | struct!(__MODULE__, params) 54 | end 55 | 56 | # helper for message serialization 57 | @doc false 58 | @spec to_map(t()) :: map() 59 | def to_map(%__MODULE__{} = message) do 60 | Map.new(message, fn {key, value} -> {Map.fetch!(@keys_to_attributes, key), value} end) 61 | end 62 | 63 | defimpl Membrane.RTMP.Messages.Serializer do 64 | require Membrane.RTMP.Header 65 | 66 | @impl true 67 | def serialize(%@for{} = message) do 68 | Encoder.encode([@for.to_map(message)]) 69 | end 70 | 71 | @impl true 72 | def type(%@for{}), do: Membrane.RTMP.Header.type(:amf_data) 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/serializer.ex: -------------------------------------------------------------------------------- 1 | defprotocol Membrane.RTMP.Messages.Serializer do 2 | @moduledoc false 3 | 4 | @doc """ 5 | Serializes an RTMP message (without header) into RTMP body binary format. 6 | """ 7 | @spec serialize(struct()) :: binary() 8 | def serialize(message) 9 | 10 | @doc """ 11 | Returns the message's type required by the RTMP header. 12 | """ 13 | @spec type(struct()) :: non_neg_integer() 14 | def type(message) 15 | end 16 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/set_chunk_size.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.SetChunkSize do 2 | @moduledoc false 3 | 4 | @behaviour Membrane.RTMP.Message 5 | 6 | @enforce_keys [:chunk_size] 7 | defstruct @enforce_keys 8 | 9 | @type t :: %__MODULE__{ 10 | chunk_size: String.t() 11 | } 12 | 13 | @impl true 14 | def deserialize(<<0::1, chunk_size::31>>) do 15 | %__MODULE__{chunk_size: chunk_size} 16 | end 17 | 18 | defimpl Membrane.RTMP.Messages.Serializer do 19 | require Membrane.RTMP.Header 20 | 21 | @impl true 22 | def serialize(%@for{chunk_size: chunk_size}) do 23 | <<0::1, chunk_size::31>> 24 | end 25 | 26 | @impl true 27 | def type(%@for{}), do: Membrane.RTMP.Header.type(:set_chunk_size) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/set_data_frame.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.SetDataFrame do 2 | @moduledoc """ 3 | Defines the RTMP `setDataFrame` command. 4 | """ 5 | 6 | @behaviour Membrane.RTMP.Message 7 | 8 | alias Membrane.RTMP.AMF0.Encoder 9 | alias Membrane.RTMP.Messages.OnExpectAdditionalMedia 10 | 11 | defstruct ~w(duration file_size encoder width height video_codec_id video_data_rate framerate audio_codec_id 12 | audio_data_rate audio_sample_rate audio_sample_size stereo)a 13 | 14 | @attributes_to_keys %{ 15 | "duration" => :duration, 16 | "fileSize" => :file_size, 17 | "filesize" => :file_size, 18 | "width" => :width, 19 | "height" => :height, 20 | "videocodecid" => :video_codec_id, 21 | "videodatarate" => :video_data_rate, 22 | "framerate" => :framerate, 23 | "audiocodecid" => :audio_codec_id, 24 | "audiodatarate" => :audio_data_rate, 25 | "audiosamplerate" => :audio_sample_rate, 26 | "audiosamplesize" => :audio_sample_size, 27 | "stereo" => :stereo, 28 | "encoder" => :encoder 29 | } 30 | 31 | @keys_to_attributes Map.new(@attributes_to_keys, fn {key, value} -> {value, key} end) 32 | 33 | @type t :: %__MODULE__{ 34 | duration: number(), 35 | file_size: number(), 36 | # video related 37 | width: number(), 38 | height: number(), 39 | video_codec_id: number(), 40 | video_data_rate: number(), 41 | framerate: number(), 42 | # audio related 43 | audio_codec_id: number(), 44 | audio_data_rate: number(), 45 | audio_sample_rate: number(), 46 | audio_sample_size: number(), 47 | stereo: boolean() 48 | } 49 | 50 | @impl true 51 | def from_data(["@setDataFrame", "onMetaData", properties]) do 52 | new(properties) 53 | end 54 | 55 | @impl true 56 | def from_data(["@setDataFrame", "onExpectAdditionalMedia", _properties] = data) do 57 | OnExpectAdditionalMedia.from_data(data) 58 | end 59 | 60 | @spec new([{String.t(), any()}]) :: t() 61 | def new(options) do 62 | params = 63 | options 64 | |> Map.new() 65 | |> Map.take(Map.keys(@attributes_to_keys)) 66 | |> Enum.map(fn {key, value} -> 67 | {Map.fetch!(@attributes_to_keys, key), value} 68 | end) 69 | 70 | struct!(__MODULE__, params) 71 | end 72 | 73 | # helper for message serialization 74 | @doc false 75 | @spec to_map(t()) :: map() 76 | def to_map(%__MODULE__{} = message) do 77 | Map.new(message, fn {key, value} -> {Map.fetch!(@keys_to_attributes, key), value} end) 78 | end 79 | 80 | defimpl Membrane.RTMP.Messages.Serializer do 81 | require Membrane.RTMP.Header 82 | 83 | @impl true 84 | def serialize(%@for{} = message) do 85 | Encoder.encode([@for.to_map(message)]) 86 | end 87 | 88 | @impl true 89 | def type(%@for{}), do: Membrane.RTMP.Header.type(:amf_data) 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/set_peer_bandwidth.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.SetPeerBandwidth do 2 | @moduledoc false 3 | 4 | @behaviour Membrane.RTMP.Message 5 | 6 | @enforce_keys [:size] 7 | defstruct @enforce_keys 8 | 9 | @type t :: %__MODULE__{ 10 | size: non_neg_integer() 11 | } 12 | 13 | @limit_type 0x02 14 | 15 | @impl true 16 | def deserialize(<>) do 17 | %__MODULE__{size: size} 18 | end 19 | 20 | defimpl Membrane.RTMP.Messages.Serializer do 21 | require Membrane.RTMP.Header 22 | 23 | @impl true 24 | def serialize(%@for{size: size}) do 25 | <> 26 | end 27 | 28 | @impl true 29 | def type(%@for{}), do: Membrane.RTMP.Header.type(:set_peer_bandwidth) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/user_control.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.UserControl do 2 | @moduledoc false 3 | 4 | @behaviour Membrane.RTMP.Message 5 | 6 | @enforce_keys [:event_type, :data] 7 | defstruct @enforce_keys 8 | 9 | @type t :: %__MODULE__{ 10 | event_type: non_neg_integer(), 11 | data: binary() 12 | } 13 | 14 | @impl true 15 | def deserialize(<>) do 16 | %__MODULE__{event_type: event_type, data: data} 17 | end 18 | 19 | defimpl Membrane.RTMP.Messages.Serializer do 20 | require Membrane.RTMP.Header 21 | 22 | @impl true 23 | def serialize(%@for{event_type: event_type, data: data}) do 24 | <> 25 | end 26 | 27 | @impl true 28 | def type(%@for{}), do: Membrane.RTMP.Header.type(:user_control_message) 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/video.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.Video do 2 | @moduledoc false 3 | 4 | @behaviour Membrane.RTMP.Message 5 | 6 | @enforce_keys [:data] 7 | defstruct @enforce_keys 8 | 9 | @type t :: %__MODULE__{ 10 | data: binary() 11 | } 12 | 13 | @impl true 14 | def deserialize(<>) do 15 | %__MODULE__{data: data} 16 | end 17 | 18 | defimpl Membrane.RTMP.Messages.Serializer do 19 | require Membrane.RTMP.Header 20 | 21 | @impl true 22 | def serialize(%@for{data: data}) do 23 | data 24 | end 25 | 26 | @impl true 27 | def type(%@for{}), do: Membrane.RTMP.Header.type(:video_message) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/messages/window_acknowledgement.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Messages.WindowAcknowledgement do 2 | @moduledoc false 3 | 4 | @behaviour Membrane.RTMP.Message 5 | 6 | @enforce_keys [:size] 7 | defstruct @enforce_keys 8 | 9 | @type t :: %__MODULE__{ 10 | size: non_neg_integer() 11 | } 12 | 13 | @impl true 14 | def deserialize(<>) do 15 | %__MODULE__{size: size} 16 | end 17 | 18 | defimpl Membrane.RTMP.Messages.Serializer do 19 | require Membrane.RTMP.Header 20 | 21 | @impl true 22 | def serialize(%@for{size: size}) do 23 | <> 24 | end 25 | 26 | @impl true 27 | def type(%@for{}), do: Membrane.RTMP.Header.type(:window_acknowledgement_size) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/responses.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Responses do 2 | @moduledoc false 3 | 4 | alias Membrane.RTMP.Messages 5 | 6 | @type transaction_id_t :: float() | non_neg_integer() 7 | 8 | @doc """ 9 | Returns a default success response on connect request. 10 | """ 11 | @spec connection_success() :: struct() 12 | def connection_success() do 13 | %Messages.Anonymous{ 14 | name: "_result", 15 | # transaction ID is always 1 for connect request/responses 16 | tx_id: 1, 17 | properties: [ 18 | %{ 19 | "fmsVer" => "FMS/3,0,1,123", 20 | "capabilities" => 31.0 21 | }, 22 | %{ 23 | "level" => "status", 24 | "code" => "NetConnection.Connect.Success", 25 | "description" => "Connection succeeded.", 26 | "objectEncoding" => 0.0 27 | } 28 | ] 29 | } 30 | end 31 | 32 | @doc """ 33 | Returns a publishment success message. 34 | """ 35 | @spec publish_success(String.t()) :: struct() 36 | def publish_success(stream_key) do 37 | %Messages.Anonymous{ 38 | name: "onStatus", 39 | # transaction ID is always 0 for publish request/responses 40 | tx_id: 0, 41 | properties: [ 42 | :null, 43 | %{ 44 | "level" => "status", 45 | "code" => "NetStream.Publish.Start", 46 | "description" => "#{stream_key} is now published", 47 | "details" => stream_key 48 | } 49 | ] 50 | } 51 | end 52 | 53 | @doc """ 54 | Returns a bandwidth measurement done message. 55 | """ 56 | @spec on_bw_done() :: struct() 57 | def on_bw_done() do 58 | %Messages.Anonymous{ 59 | name: "onBWDone", 60 | tx_id: 0, 61 | properties: [ 62 | :null, 63 | # from ffmpeg rtmp server implementation 64 | 8192.0 65 | ] 66 | } 67 | end 68 | 69 | @doc """ 70 | Returns a default `_result` response with arbitrary body. 71 | 72 | The body can be set by specifying the properties list. 73 | """ 74 | @spec default_result(transaction_id_t(), [any()]) :: struct() 75 | def default_result(tx_id, properties) do 76 | %Messages.Anonymous{ 77 | name: "_result", 78 | tx_id: tx_id, 79 | properties: properties 80 | } 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/sink/native.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Sink.Native do 2 | @moduledoc false 3 | use Unifex.Loader 4 | end 5 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/sink/sink.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Sink do 2 | @moduledoc """ 3 | Membrane element being client-side of RTMP streams. 4 | It needs to receive at least one of: video stream in H264 format or audio in AAC format. 5 | Currently it supports only: 6 | - RTMP proper - "plain" RTMP protocol 7 | - RTMPS - RTMP over TLS/SSL 8 | other RTMP variants - RTMPT, RTMPE, RTMFP are not supported. 9 | Implementation based on FFmpeg. 10 | """ 11 | use Membrane.Sink 12 | 13 | require Membrane.{H264, Logger} 14 | 15 | alias __MODULE__.Native 16 | alias Membrane.{AAC, Buffer, H264} 17 | 18 | @supported_protocols ["rtmp://", "rtmps://"] 19 | @connection_attempt_interval 500 20 | @type track_type :: :audio | :video 21 | 22 | def_input_pad :audio, 23 | availability: :on_request, 24 | accepted_format: AAC, 25 | flow_control: :manual, 26 | demand_unit: :buffers 27 | 28 | def_input_pad :video, 29 | availability: :on_request, 30 | accepted_format: %H264{stream_structure: structure} when H264.is_avc(structure), 31 | flow_control: :manual, 32 | demand_unit: :buffers 33 | 34 | def_options rtmp_url: [ 35 | spec: String.t(), 36 | description: """ 37 | Destination URL of the stream. It needs to start with rtmp:// or rtmps:// depending on the protocol variant. 38 | This URL should be provided by your streaming service. 39 | """ 40 | ], 41 | max_attempts: [ 42 | spec: pos_integer() | :infinity, 43 | default: 1, 44 | description: """ 45 | Maximum number of connection attempts before failing with an error. 46 | The attempts will happen every #{@connection_attempt_interval} ms 47 | """ 48 | ], 49 | tracks: [ 50 | spec: [track_type()], 51 | default: [:audio, :video], 52 | description: """ 53 | A list of tracks, which will be sent. Can be `:audio`, `:video` or both. 54 | """ 55 | ], 56 | reset_timestamps: [ 57 | spec: boolean(), 58 | default: true, 59 | description: """ 60 | If enabled, this feature adjusts the timing of outgoing FLV packets so that they begin from zero. 61 | Leaves it untouched otherwise. 62 | """ 63 | ] 64 | 65 | @impl true 66 | def handle_init(_ctx, options) do 67 | unless String.starts_with?(options.rtmp_url, @supported_protocols) do 68 | raise ArgumentError, "Invalid destination URL provided" 69 | end 70 | 71 | unless options.max_attempts == :infinity or 72 | (is_integer(options.max_attempts) and options.max_attempts >= 1) do 73 | raise ArgumentError, "Invalid max_attempts option value: #{options.max_attempts}" 74 | end 75 | 76 | options = %{options | tracks: Enum.uniq(options.tracks)} 77 | 78 | unless length(options.tracks) > 0 and 79 | Enum.all?(options.tracks, &Kernel.in(&1, [:audio, :video])) do 80 | raise ArgumentError, "All track have to be either :audio or :video" 81 | end 82 | 83 | single_track? = length(options.tracks) == 1 84 | frame_buffer = Enum.map(options.tracks, &{Pad.ref(&1, 0), nil}) |> Enum.into(%{}) 85 | 86 | state = 87 | options 88 | |> Map.from_struct() 89 | |> Map.merge(%{ 90 | attempts: 0, 91 | native: nil, 92 | # Keys here are the pad names. 93 | frame_buffer: frame_buffer, 94 | ready?: false, 95 | # Activated when one of the source inputs gets closed. Interleaving is 96 | # disabled, frame buffer is flushed and from that point buffers on the 97 | # remaining pad are simply forwarded to the output. 98 | # Always on if a single track is connected 99 | forward_mode?: single_track?, 100 | video_base_dts: nil, 101 | reset_timestampts: options.reset_timestamps 102 | }) 103 | 104 | {[], state} 105 | end 106 | 107 | @impl true 108 | def handle_setup(_ctx, state) do 109 | audio? = :audio in state.tracks 110 | video? = :video in state.tracks 111 | 112 | {:ok, native} = Native.create(state.rtmp_url, audio?, video?) 113 | 114 | state 115 | |> Map.put(:native, native) 116 | |> try_connect() 117 | |> then(&{[], &1}) 118 | end 119 | 120 | @impl true 121 | def handle_playing(_ctx, state) do 122 | {build_demand(state), state} 123 | end 124 | 125 | @impl true 126 | def handle_pad_added(Pad.ref(_type, stream_id), _ctx, _state) when stream_id != 0, 127 | do: raise(ArgumentError, message: "Stream id must always be 0") 128 | 129 | @impl true 130 | def handle_pad_added(_pad, _ctx, state) do 131 | {[], state} 132 | end 133 | 134 | @impl true 135 | def handle_stream_format( 136 | Pad.ref(:video, 0), 137 | %H264{width: width, height: height, stream_structure: {_avc, dcr}}, 138 | _ctx, 139 | state 140 | ) do 141 | case Native.init_video_stream(state.native, width, height, dcr) do 142 | {:ok, ready?, native} -> 143 | Membrane.Logger.debug("Correctly initialized video stream.") 144 | {[], %{state | native: native, ready?: ready?}} 145 | 146 | {:error, :stream_format_resent} -> 147 | Membrane.Logger.warning( 148 | "Input stream format redefined on pad :video. RTMP Sink does not support dynamic stream parameters" 149 | ) 150 | 151 | {[], state} 152 | end 153 | end 154 | 155 | @impl true 156 | def handle_stream_format(Pad.ref(:audio, 0), %Membrane.AAC{} = stream_format, _ctx, state) do 157 | profile = AAC.profile_to_aot_id(stream_format.profile) 158 | sr_index = AAC.sample_rate_to_sampling_frequency_id(stream_format.sample_rate) 159 | channel_configuration = AAC.channels_to_channel_config_id(stream_format.channels) 160 | frame_length_id = AAC.samples_per_frame_to_frame_length_id(stream_format.samples_per_frame) 161 | 162 | aac_config = 163 | <> 164 | 165 | case Native.init_audio_stream( 166 | state.native, 167 | stream_format.channels, 168 | stream_format.sample_rate, 169 | aac_config 170 | ) do 171 | {:ok, ready?, native} -> 172 | Membrane.Logger.debug("Correctly initialized audio stream.") 173 | {[], %{state | native: native, ready?: ready?}} 174 | 175 | {:error, :stream_format_resent} -> 176 | Membrane.Logger.warning( 177 | "Input stream format redefined on pad :audio. RTMP Sink does not support dynamic stream parameters" 178 | ) 179 | 180 | {[], state} 181 | end 182 | end 183 | 184 | @impl true 185 | def handle_buffer(pad, buffer, _ctx, %{ready?: false} = state) do 186 | {[], fill_frame_buffer(state, pad, buffer)} 187 | end 188 | 189 | def handle_buffer(pad, buffer, _ctx, %{forward_mode?: true} = state) do 190 | {[demand: pad], write_frame(state, pad, buffer)} 191 | end 192 | 193 | def handle_buffer(pad, buffer, _ctx, state) do 194 | state 195 | |> fill_frame_buffer(pad, buffer) 196 | |> write_frame_interleaved() 197 | end 198 | 199 | @impl true 200 | def handle_end_of_stream(Pad.ref(type, 0), _ctx, state) do 201 | cond do 202 | state.forward_mode? -> 203 | Native.finalize_stream(state.native) 204 | {[], state} 205 | 206 | state.ready? -> 207 | # The interleave logic does not work if either one of the inputs does not 208 | # produce buffers. From this point on we act as a "forward" filter. 209 | other_pad = 210 | case type do 211 | :audio -> :video 212 | :video -> :audio 213 | end 214 | |> then(&Pad.ref(&1, 0)) 215 | 216 | state = flush_frame_buffer(state) 217 | {[demand: other_pad], %{state | forward_mode?: true}} 218 | 219 | true -> 220 | {[], state} 221 | end 222 | end 223 | 224 | defp try_connect(%{attempts: attempts, max_attempts: max_attempts} = state) 225 | when max_attempts != :infinity and attempts >= max_attempts do 226 | raise "failed to connect to '#{state.rtmp_url}' #{attempts} times, aborting" 227 | end 228 | 229 | defp try_connect(state) do 230 | state = %{state | attempts: state.attempts + 1} 231 | 232 | case Native.try_connect(state.native) do 233 | :ok -> 234 | Membrane.Logger.debug("Correctly initialized connection with: #{state.rtmp_url}") 235 | 236 | state 237 | 238 | {:error, error} when error in [:econnrefused, :etimedout] -> 239 | Membrane.Logger.warning( 240 | "Connection to #{state.rtmp_url} refused, retrying in #{@connection_attempt_interval}ms" 241 | ) 242 | 243 | Process.sleep(@connection_attempt_interval) 244 | 245 | try_connect(state) 246 | 247 | {:error, reason} -> 248 | raise "failed to connect to '#{state.rtmp_url}': #{inspect(reason)}" 249 | end 250 | end 251 | 252 | defp build_demand(%{frame_buffer: frame_buffer}) do 253 | frame_buffer 254 | |> Enum.filter(fn {_pad, buffer} -> buffer == nil end) 255 | |> Enum.map(fn {pad, _buffer} -> {:demand, pad} end) 256 | end 257 | 258 | defp fill_frame_buffer(state, pad, buffer) do 259 | if get_in(state, [:frame_buffer, pad]) == nil do 260 | put_in(state, [:frame_buffer, pad], buffer) 261 | else 262 | raise "attempted to overwrite frame buffer on pad #{inspect(pad)}" 263 | end 264 | end 265 | 266 | defp write_frame_interleaved( 267 | %{ 268 | frame_buffer: %{Pad.ref(:audio, 0) => audio, Pad.ref(:video, 0) => video} 269 | } = state 270 | ) 271 | when audio == nil or video == nil do 272 | # We still have to wait for the other frame. 273 | {[], state} 274 | end 275 | 276 | defp write_frame_interleaved(%{frame_buffer: frame_buffer} = state) do 277 | {pad, buffer} = 278 | Enum.min_by(frame_buffer, fn {_pad, buffer} -> 279 | buffer 280 | |> Buffer.get_dts_or_pts() 281 | |> Ratio.ceil() 282 | end) 283 | 284 | state = 285 | state 286 | |> write_frame(pad, buffer) 287 | |> put_in([:frame_buffer, pad], nil) 288 | 289 | {build_demand(state), state} 290 | end 291 | 292 | defp flush_frame_buffer(%{frame_buffer: frame_buffer} = state) do 293 | pads_with_buffer = 294 | frame_buffer 295 | |> Enum.filter(fn {_pad, buffer} -> buffer != nil end) 296 | |> Enum.sort(fn {_, left}, {_, right} -> 297 | Buffer.get_dts_or_pts(left) <= Buffer.get_dts_or_pts(right) 298 | end) 299 | 300 | Enum.reduce(pads_with_buffer, state, fn {pad, buffer}, state -> 301 | state 302 | |> write_frame(pad, buffer) 303 | |> put_in([:frame_buffer, pad], nil) 304 | end) 305 | end 306 | 307 | defp write_frame(state, Pad.ref(:audio, 0), buffer) do 308 | buffer_pts = Ratio.ceil(buffer.pts) 309 | 310 | case Native.write_audio_frame(state.native, buffer.payload, buffer_pts) do 311 | {:ok, native} -> 312 | Map.put(state, :native, native) 313 | 314 | {:error, reason} -> 315 | raise "writing audio frame failed with reason: #{inspect(reason)}" 316 | end 317 | end 318 | 319 | defp write_frame(state, Pad.ref(:video, 0), buffer) do 320 | {{dts, pts}, state} = buffer_timings(buffer, state) 321 | 322 | case Native.write_video_frame( 323 | state.native, 324 | buffer.payload, 325 | dts, 326 | pts, 327 | buffer.metadata.h264.key_frame? 328 | ) do 329 | {:ok, native} -> 330 | Map.put(state, :native, native) 331 | 332 | {:error, reason} -> 333 | raise "writing video frame failed with reason: #{inspect(reason)}" 334 | end 335 | end 336 | 337 | defp buffer_timings(buffer, state) do 338 | dts = buffer.dts || buffer.pts 339 | pts = buffer.pts || buffer.dts 340 | correct_timings(dts, pts, state) 341 | end 342 | 343 | defp correct_timings(dts, pts, %{reset_timestamps: false} = state) do 344 | {{dts, pts}, state} 345 | end 346 | 347 | defp correct_timings(dts, pts, state) do 348 | {base_dts, state} = Bunch.Map.get_updated!(state, :video_base_dts, &(&1 || dts)) 349 | {{dts - base_dts, pts - base_dts}, state} 350 | end 351 | end 352 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/source/client_handler_impl.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Source.ClientHandlerImpl do 2 | @moduledoc """ 3 | An implementation of `Membrane.RTMPServer.ClienHandlerBehaviour` compatible with the 4 | `Membrane.RTMP.Source` element. 5 | """ 6 | 7 | @behaviour Membrane.RTMPServer.ClientHandler 8 | 9 | defstruct [] 10 | 11 | @impl true 12 | def handle_init(_opts) do 13 | %{ 14 | source_pid: nil, 15 | buffered: [] 16 | } 17 | end 18 | 19 | @impl true 20 | def handle_info({:send_me_data, source_pid}, state) do 21 | buffers_to_send = Enum.reverse(state.buffered) 22 | state = %{state | source_pid: source_pid, buffered: []} 23 | Enum.each(buffers_to_send, fn buffer -> send_data(state.source_pid, buffer) end) 24 | state 25 | end 26 | 27 | @impl true 28 | def handle_info(_other, state) do 29 | state 30 | end 31 | 32 | @impl true 33 | def handle_data_available(payload, state) do 34 | if state.source_pid do 35 | :ok = send_data(state.source_pid, payload) 36 | state 37 | else 38 | %{state | buffered: [payload | state.buffered]} 39 | end 40 | end 41 | 42 | @impl true 43 | def handle_connection_closed(state) do 44 | if state.source_pid != nil, do: send(state.source_pid, :connection_closed) 45 | state 46 | end 47 | 48 | @impl true 49 | def handle_delete_stream(state) do 50 | if state.source_pid != nil, do: send(state.source_pid, :delete_stream) 51 | state 52 | end 53 | 54 | defp send_data(pid, payload) do 55 | send(pid, {:data, payload}) 56 | :ok 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/source/source.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Source do 2 | @moduledoc """ 3 | Membrane Element for receiving an RTMP stream. Acts as a RTMP Server. 4 | This implementation is limited to only AAC and H264 streams. 5 | 6 | The source can be used in the following two scenarios: 7 | * by providing the URL on which the client is expected to connect - note, that if the client doesn't 8 | connect on this URL, the source won't complete its setup. Note that all attempted connections to 9 | other `app` or `stream_key` than specified ones will be rejected. 10 | 11 | * by spawning `Membrane.RTMPServer`, receiving a client reference and passing it to the `#{inspect(__MODULE__)}`. 12 | """ 13 | use Membrane.Source 14 | require Membrane.Logger 15 | require Logger 16 | alias Membrane.RTMPServer.ClientHandler 17 | 18 | def_output_pad :output, 19 | availability: :always, 20 | accepted_format: Membrane.RemoteStream, 21 | flow_control: :manual, 22 | demand_unit: :buffers 23 | 24 | def_options client_ref: [ 25 | default: nil, 26 | spec: pid(), 27 | description: """ 28 | A pid of a process acting as a client reference. 29 | Can be gained with the use of `Membrane.RTMPServer`. 30 | """ 31 | ], 32 | url: [ 33 | default: nil, 34 | spec: String.t(), 35 | description: """ 36 | An URL on which the client is expected to connect, for example: 37 | rtmp://127.0.0.1:1935/app/stream_key 38 | """ 39 | ], 40 | client_timeout: [ 41 | default: Membrane.Time.seconds(5), 42 | spec: Membrane.Time.t(), 43 | description: """ 44 | Time after which an unused client connection is automatically closed, expressed in `Membrane.Time.t()` units. Defaults to 5 seconds. 45 | """ 46 | ] 47 | 48 | defguardp is_builtin_server(opts) 49 | when not is_nil(opts.url) and is_nil(opts.client_ref) 50 | 51 | defguardp is_external_server(opts) 52 | when not is_nil(opts.client_ref) and 53 | is_nil(opts.url) 54 | 55 | @impl true 56 | def handle_init(_ctx, opts) when is_builtin_server(opts) do 57 | state = %{ 58 | app: nil, 59 | stream_key: nil, 60 | server: nil, 61 | url: opts.url, 62 | mode: :builtin_server, 63 | client_ref: nil, 64 | use_ssl?: nil, 65 | client_timeout: opts.client_timeout 66 | } 67 | 68 | {[], state} 69 | end 70 | 71 | @impl true 72 | def handle_init(_ctx, opts) when is_external_server(opts) do 73 | state = %{ 74 | mode: :external_server, 75 | client_ref: opts.client_ref 76 | } 77 | 78 | {[], state} 79 | end 80 | 81 | @impl true 82 | def handle_init(_ctx, opts) do 83 | raise """ 84 | Improper options passed to the `#{__MODULE__}`: 85 | #{inspect(opts)} 86 | """ 87 | end 88 | 89 | @impl true 90 | def handle_setup(_ctx, %{mode: :builtin_server} = state) do 91 | {use_ssl?, port, app, stream_key} = Membrane.RTMPServer.parse_url(state.url) 92 | 93 | parent_pid = self() 94 | 95 | handle_new_client = fn client_ref, app, stream_key -> 96 | send(parent_pid, {:client_ref, client_ref, app, stream_key}) 97 | __MODULE__.ClientHandlerImpl 98 | end 99 | 100 | {:ok, server_pid} = 101 | Membrane.RTMPServer.start_link( 102 | port: port, 103 | use_ssl?: use_ssl?, 104 | handle_new_client: handle_new_client, 105 | client_timeout: state.client_timeout 106 | ) 107 | 108 | state = %{state | app: app, stream_key: stream_key, server: server_pid} 109 | {[setup: :incomplete], state} 110 | end 111 | 112 | @impl true 113 | def handle_setup(_ctx, %{mode: :external_server} = state) do 114 | {[], state} 115 | end 116 | 117 | @impl true 118 | def handle_playing(_ctx, %{mode: :external_server} = state) do 119 | stream_format = [ 120 | stream_format: 121 | {:output, %Membrane.RemoteStream{content_format: Membrane.FLV, type: :bytestream}} 122 | ] 123 | 124 | send(state.client_ref, {:send_me_data, self()}) 125 | 126 | {stream_format, state} 127 | end 128 | 129 | @impl true 130 | def handle_playing(_ctx, %{mode: :builtin_server} = state) do 131 | stream_format = [ 132 | stream_format: 133 | {:output, %Membrane.RemoteStream{content_format: Membrane.FLV, type: :bytestream}} 134 | ] 135 | 136 | {stream_format, state} 137 | end 138 | 139 | @impl true 140 | def handle_demand( 141 | :output, 142 | _size, 143 | :buffers, 144 | _ctx, 145 | %{client_ref: nil, mode: :builtin_server} = state 146 | ) do 147 | {[], state} 148 | end 149 | 150 | @impl true 151 | def handle_demand( 152 | :output, 153 | size, 154 | :buffers, 155 | _ctx, 156 | %{client_ref: client_ref, mode: :builtin_server} = state 157 | ) do 158 | :ok = ClientHandler.demand_data(client_ref, size) 159 | send(client_ref, {:send_me_data, self()}) 160 | {[], state} 161 | end 162 | 163 | @impl true 164 | def handle_demand( 165 | :output, 166 | size, 167 | :buffers, 168 | _ctx, 169 | %{client_ref: client_ref, mode: :external_server} = state 170 | ) do 171 | :ok = ClientHandler.demand_data(client_ref, size) 172 | {[], state} 173 | end 174 | 175 | @impl true 176 | def handle_info( 177 | {:client_ref, client_ref, app, stream_key}, 178 | _ctx, 179 | %{mode: :builtin_server} = state 180 | ) 181 | when app == state.app and stream_key == state.stream_key do 182 | {[setup: :complete], %{state | client_ref: client_ref}} 183 | end 184 | 185 | @impl true 186 | def handle_info( 187 | {:client_ref, _client_ref, app, stream_key}, 188 | _ctx, 189 | %{mode: :builtin_server} = state 190 | ) do 191 | Logger.warning("Unexpected client connected on /#{app}/#{stream_key}") 192 | {[], state} 193 | end 194 | 195 | @impl true 196 | def handle_info({:data, data}, _ctx, state) do 197 | {[buffer: {:output, %Membrane.Buffer{payload: data}}, redemand: :output], state} 198 | end 199 | 200 | @impl true 201 | def handle_info(:connection_closed, ctx, state) do 202 | if ctx.pads[:output].end_of_stream? do 203 | {[], state} 204 | else 205 | {[end_of_stream: :output], state} 206 | end 207 | end 208 | 209 | def handle_info(:delete_stream, _ctx, state) do 210 | {[notify_parent: :stream_deleted], state} 211 | end 212 | 213 | @impl true 214 | def handle_terminate_request(_ctx, state) do 215 | {[terminate: :normal], state} 216 | end 217 | end 218 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp/source/source_bin.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.SourceBin do 2 | @moduledoc """ 3 | Bin responsible for demuxing and parsing an RTMP stream. 4 | 5 | Outputs single audio and video which are ready for further processing with Membrane Elements. 6 | At this moment only AAC and H264 codecs are supported. 7 | 8 | The bin can be used in the following two scenarios: 9 | * by providing the URL on which the client is expected to connect - note, that if the client doesn't 10 | connect on this URL, the bin won't complete its setup 11 | * by spawning `Membrane.RTMPServer`, receiving client reference after client connects on a given `app` and `stream_key` 12 | and passing the client reference to the `#{inspect(__MODULE__)}`. 13 | """ 14 | use Membrane.Bin 15 | 16 | require Membrane.Logger 17 | alias Membrane.{AAC, H264, RTMP} 18 | 19 | def_output_pad :video, 20 | accepted_format: H264, 21 | availability: :on_request 22 | 23 | def_output_pad :audio, 24 | accepted_format: AAC, 25 | availability: :on_request 26 | 27 | def_options client_ref: [ 28 | default: nil, 29 | spec: pid(), 30 | description: """ 31 | A pid of a process acting as a client reference. 32 | Can be gained with the use of `Membrane.RTMPServer`. 33 | """ 34 | ], 35 | url: [ 36 | default: nil, 37 | spec: String.t(), 38 | description: """ 39 | An URL on which the client is expected to connect, for example: 40 | rtmp://127.0.0.1:1935/app/stream_key 41 | """ 42 | ], 43 | client_timeout: [ 44 | default: Membrane.Time.seconds(5), 45 | spec: Membrane.Time.t(), 46 | description: """ 47 | Time after which an unused client connection is automatically closed, expressed in `Membrane.Time.t()` units. Defaults to 5 seconds. 48 | """ 49 | ] 50 | 51 | @impl true 52 | def handle_init(_ctx, %__MODULE__{} = opts) do 53 | spec = 54 | child(:src, %RTMP.Source{ 55 | client_ref: opts.client_ref, 56 | url: opts.url, 57 | client_timeout: opts.client_timeout 58 | }) 59 | |> child(:demuxer, Membrane.FLV.Demuxer) 60 | 61 | state = %{ 62 | demuxer_audio_pad_ref: nil, 63 | demuxer_video_pad_ref: nil 64 | } 65 | 66 | {[spec: spec], state} 67 | end 68 | 69 | @impl true 70 | def handle_pad_added(Pad.ref(:audio, _ref) = pad, ctx, state) do 71 | assert_pad_count!(:audio, ctx) 72 | 73 | spec = 74 | child(:funnel_audio, Membrane.Funnel, get_if_exists: true) 75 | |> bin_output(pad) 76 | 77 | {actions, state} = maybe_link_audio_pad(state) 78 | 79 | {[spec: spec] ++ actions, state} 80 | end 81 | 82 | def handle_pad_added(Pad.ref(:video, _ref) = pad, ctx, state) do 83 | assert_pad_count!(:video, ctx) 84 | 85 | spec = 86 | child(:funnel_video, Membrane.Funnel, get_if_exists: true) 87 | |> bin_output(pad) 88 | 89 | {actions, state} = maybe_link_video_pad(state) 90 | 91 | {[spec: spec] ++ actions, state} 92 | end 93 | 94 | @impl true 95 | def handle_child_notification({:new_stream, pad_ref, :AAC}, :demuxer, _ctx, state) do 96 | maybe_link_audio_pad(%{state | demuxer_audio_pad_ref: pad_ref}) 97 | end 98 | 99 | def handle_child_notification({:new_stream, pad_ref, :H264}, :demuxer, _ctx, state) do 100 | maybe_link_video_pad(%{state | demuxer_video_pad_ref: pad_ref}) 101 | end 102 | 103 | def handle_child_notification( 104 | {type, _socket, _pid} = notification, 105 | :src, 106 | _ctx, 107 | state 108 | ) 109 | when type in [:socket_control_needed, :ssl_socket_control_needed] do 110 | {[notify_parent: notification], state} 111 | end 112 | 113 | def handle_child_notification( 114 | {type, _stage, _reason} = notification, 115 | :src, 116 | _ctx, 117 | state 118 | ) 119 | when type in [:stream_validation_success, :stream_validation_error] do 120 | {[notify_parent: notification], state} 121 | end 122 | 123 | def handle_child_notification(:unexpected_socket_closed, :src, _ctx, state) do 124 | {[notify_parent: :unexpected_socket_close], state} 125 | end 126 | 127 | def handle_child_notification(:stream_deleted, :src, _ctx, state) do 128 | {[notify_parent: :stream_deleted], state} 129 | end 130 | 131 | def handle_child_notification(notification, child, _ctx, state) do 132 | Membrane.Logger.warning( 133 | "Received unrecognized child notification from: #{inspect(child)}: #{inspect(notification)}" 134 | ) 135 | 136 | {[], state} 137 | end 138 | 139 | @doc """ 140 | Passes the control of the socket to the `source`. 141 | 142 | To succeed, the executing process must be in control of the socket, otherwise `{:error, :not_owner}` is returned. 143 | """ 144 | @spec pass_control(:gen_tcp.socket(), pid()) :: :ok | {:error, atom()} 145 | def pass_control(socket, source) do 146 | :gen_tcp.controlling_process(socket, source) 147 | end 148 | 149 | @doc """ 150 | Passes the control of the ssl socket to the `source`. 151 | 152 | To succeed, the executing process must be in control of the socket, otherwise `{:error, :not_owner}` is returned. 153 | """ 154 | @spec secure_pass_control(:ssl.sslsocket(), pid()) :: :ok | {:error, any()} 155 | def secure_pass_control(socket, source) do 156 | :ssl.controlling_process(socket, source) 157 | end 158 | 159 | defp maybe_link_audio_pad(state) when state.demuxer_audio_pad_ref != nil do 160 | {[ 161 | spec: 162 | get_child(:demuxer) 163 | |> via_out(state.demuxer_audio_pad_ref) 164 | |> child(:audio_parser, %Membrane.AAC.Parser{ 165 | out_encapsulation: :none 166 | }) 167 | |> child(:funnel_audio, Membrane.Funnel, get_if_exists: true) 168 | ], state} 169 | end 170 | 171 | defp maybe_link_audio_pad(state) do 172 | {[], state} 173 | end 174 | 175 | defp maybe_link_video_pad(state) when state.demuxer_video_pad_ref != nil do 176 | {[ 177 | spec: 178 | get_child(:demuxer) 179 | |> via_out(state.demuxer_video_pad_ref) 180 | |> child(:video_parser, Membrane.H264.Parser) 181 | |> child(:funnel_video, Membrane.Funnel, get_if_exists: true) 182 | ], state} 183 | end 184 | 185 | defp maybe_link_video_pad(state) do 186 | {[], state} 187 | end 188 | 189 | defp assert_pad_count!(name, ctx) do 190 | count = 191 | ctx.pads 192 | |> Map.keys() 193 | |> Enum.count(fn pad_ref -> Pad.name_by_ref(pad_ref) == name end) 194 | 195 | if count > 1 do 196 | raise( 197 | "Linking more than one #{inspect(name)} output pad to #{inspect(__MODULE__)} is not allowed" 198 | ) 199 | end 200 | 201 | :ok 202 | end 203 | end 204 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp_server.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMPServer do 2 | @moduledoc """ 3 | A simple RTMP server, which handles each new incoming connection. When a new client connects, the `handle_new_client` is invoked. 4 | New connections remain in an incomplete RTMP handshake state, until another process makes demand for their data. 5 | If no data is demanded within the client_timeout period, TCP socket is closed. 6 | 7 | Options: 8 | - handle_new_client: An anonymous function called when a new client connects. 9 | It receives the client reference, `app` and `stream_key`, allowing custom processing, 10 | like sending the reference to another process. The function should return a `t:#{inspect(__MODULE__)}.client_behaviour_spec/0` 11 | which defines how the client should behave. 12 | - port: Port on which RTMP server will listen. Defaults to 1935. 13 | - use_ssl?: If true, SSL socket (for RTMPS) will be used. Othwerwise, TCP socket (for RTMP) will be used. Defaults to false. 14 | - client_timeout: Time after which an unused client connection is automatically closed, expressed in `Membrane.Time.t()` units. Defaults to 5 seconds. 15 | - name: If not nil, value of this field will be used as a name under which the server's process will be registered. Defaults to nil. 16 | """ 17 | use GenServer 18 | 19 | require Logger 20 | 21 | alias Membrane.RTMPServer.ClientHandler 22 | 23 | @typedoc """ 24 | Defines options for the RTMP server. 25 | """ 26 | @type t :: [ 27 | port: :inet.port_number(), 28 | use_ssl?: boolean(), 29 | name: atom() | nil, 30 | handle_new_client: (client_ref :: pid(), app :: String.t(), stream_key :: String.t() -> 31 | client_behaviour_spec()), 32 | client_timeout: Membrane.Time.t() 33 | ] 34 | 35 | @default_options %{ 36 | port: 1935, 37 | use_ssl?: false, 38 | name: nil, 39 | client_timeout: Membrane.Time.seconds(5) 40 | } 41 | 42 | @typedoc """ 43 | A type representing how a client handler should behave. 44 | If just a tuple is passed, the second element of that tuple is used as 45 | an input argument of the `c:#{inspect(ClientHandler)}.handle_init/1`. Otherwise, an empty 46 | map is passed to the `c:#{inspect(ClientHandler)}.handle_init/1`. 47 | """ 48 | @type client_behaviour_spec :: ClientHandler.t() | {ClientHandler.t(), opts :: any()} 49 | 50 | @type server_identifier :: pid() | atom() 51 | 52 | @doc """ 53 | Starts the RTMP server. 54 | """ 55 | @spec start_link(server_options :: t()) :: GenServer.on_start() 56 | def start_link(server_options) do 57 | gen_server_opts = if server_options[:name] == nil, do: [], else: [name: server_options[:name]] 58 | server_options_map = Enum.into(server_options, %{}) 59 | server_options_map = Map.merge(@default_options, server_options_map) 60 | 61 | GenServer.start_link(__MODULE__, server_options_map, gen_server_opts) 62 | end 63 | 64 | @doc """ 65 | Returns the port on which the server listens for connection. 66 | """ 67 | @spec get_port(server_identifier()) :: :inet.port_number() 68 | def get_port(server_identifier) do 69 | GenServer.call(server_identifier, :get_port) 70 | end 71 | 72 | @impl true 73 | def init(server_options) do 74 | pid = 75 | Task.start_link(Membrane.RTMPServer.Listener, :run, [ 76 | Map.merge(server_options, %{server: self()}) 77 | ]) 78 | 79 | {:ok, 80 | %{ 81 | listener: pid, 82 | port: nil, 83 | to_reply: [], 84 | use_ssl?: server_options.use_ssl? 85 | }} 86 | end 87 | 88 | @impl true 89 | def handle_call(:get_port, from, state) do 90 | if state.port do 91 | {:reply, state.port, state} 92 | else 93 | {:noreply, %{state | to_reply: [from | state.to_reply]}} 94 | end 95 | end 96 | 97 | @impl true 98 | def handle_info({:port, port}, state) do 99 | Enum.each(state.to_reply, &GenServer.reply(&1, port)) 100 | {:noreply, %{state | port: port, to_reply: []}} 101 | end 102 | 103 | @doc """ 104 | Extracts ssl, port, app and stream_key from url. 105 | """ 106 | @spec parse_url(url :: String.t()) :: {boolean(), integer(), String.t(), String.t()} 107 | def parse_url(url) do 108 | uri = URI.parse(url) 109 | port = uri.port 110 | 111 | {app, stream_key} = 112 | case (uri.path || "") 113 | |> String.trim_leading("/") 114 | |> String.trim_trailing("/") 115 | |> String.split("/") do 116 | [app, stream_key] -> {app, stream_key} 117 | [app] -> {app, ""} 118 | end 119 | 120 | use_ssl? = 121 | case uri.scheme do 122 | "rtmp" -> false 123 | "rtmps" -> true 124 | end 125 | 126 | {use_ssl?, port, app, stream_key} 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp_server/client_handler.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMPServer.ClientHandler do 2 | @moduledoc """ 3 | A behaviour describing the actions that might be taken by the client 4 | handler in response to different events. 5 | """ 6 | 7 | # It also containts functions responsible for maintaining the lifecycle of the 8 | # client connection. 9 | 10 | use GenServer 11 | 12 | require Logger 13 | alias Membrane.RTMP.{Handshake, MessageHandler, MessageParser, Messages} 14 | 15 | @typedoc """ 16 | A type representing a module which implements `#{inspect(__MODULE__)}` behaviour. 17 | """ 18 | @type t :: module() 19 | 20 | @typedoc """ 21 | Type representing the user defined state of the client handler. 22 | """ 23 | @type state :: any() 24 | 25 | @doc """ 26 | The callback invoked once the client handler is created. 27 | It should return the initial state of the client handler. 28 | """ 29 | @callback handle_init(any()) :: state() 30 | 31 | @doc """ 32 | The callback invoked when new piece of data is received from a given client. 33 | """ 34 | @callback handle_data_available(payload :: binary(), state :: state()) :: state() 35 | 36 | @doc """ 37 | Callback invoked when the RMTP stream is finished. 38 | """ 39 | @callback handle_delete_stream(state :: state()) :: state() 40 | 41 | @doc """ 42 | Callback invoked when the socket connection is terminated. In normal 43 | conditions, `handle_delete_stream` is called before this one. If delete_stream 44 | is not called and connection_closed is, it might just mean that the 45 | connection was lost (for instance when TCP socket is closed unexpectedly). 46 | 47 | It is up to the users to determined how to handle it in their case. 48 | """ 49 | @callback handle_connection_closed(state :: state()) :: state() 50 | 51 | @doc """ 52 | The optional callback invoked when the client handler receives RTMP message with 53 | metadata information (just like a resolution or a framerate of a video stream). 54 | The following messages are considered the ones that contain metadata: 55 | 1) OnMetaData 56 | 2) SetDataFrame 57 | """ 58 | @callback handle_metadata( 59 | {:metadata_message, Messages.OnMetaData.t() | Messages.SetDataFrame.t()}, 60 | state() 61 | ) :: state() 62 | 63 | @doc """ 64 | The callback invoked when the client handler receives a message 65 | that is not recognized as an internal message of the client handler. 66 | """ 67 | @callback handle_info(msg :: term(), state()) :: state() 68 | 69 | @optional_callbacks handle_metadata: 2 70 | 71 | defguardp is_metadata_message(message) 72 | when is_struct(message, Messages.OnMetaData) or 73 | is_struct(message, Messages.SetDataFrame) 74 | 75 | @doc """ 76 | Makes the client handler ask client for the desired number of buffers 77 | """ 78 | @spec demand_data(pid(), non_neg_integer()) :: :ok 79 | def demand_data(client_reference, how_many_buffers_demanded) do 80 | send(client_reference, {:demand_data, how_many_buffers_demanded}) 81 | :ok 82 | end 83 | 84 | @impl true 85 | def init(opts) do 86 | opts = Map.new(opts) 87 | message_parser_state = Handshake.init_server() |> MessageParser.init() 88 | message_handler_state = MessageHandler.init(%{socket: opts.socket, use_ssl?: opts.use_ssl?}) 89 | 90 | {:ok, 91 | %{ 92 | socket: opts.socket, 93 | use_ssl?: opts.use_ssl?, 94 | message_parser_state: message_parser_state, 95 | message_handler_state: message_handler_state, 96 | handler: nil, 97 | handler_state: nil, 98 | app: nil, 99 | stream_key: nil, 100 | server: opts.server, 101 | buffers_demanded: 0, 102 | published?: false, 103 | notified_about_client?: false, 104 | handle_new_client: opts.handle_new_client, 105 | client_timeout: opts.client_timeout 106 | }} 107 | end 108 | 109 | @impl true 110 | def handle_info({:tcp, socket, data}, %{use_ssl?: false} = state) when state.socket == socket do 111 | handle_data(data, state) 112 | end 113 | 114 | @impl true 115 | def handle_info({:tcp_closed, socket}, %{use_ssl?: false} = state) 116 | when state.socket == socket do 117 | events = [:connection_closed] 118 | state = Enum.reduce(events, state, &handle_event/2) 119 | 120 | {:noreply, state} 121 | end 122 | 123 | @impl true 124 | def handle_info({:ssl, socket, data}, %{use_ssl?: true} = state) when state.socket == socket do 125 | handle_data(data, state) 126 | end 127 | 128 | @impl true 129 | def handle_info({:ssl_closed, socket}, %{use_ssl?: true} = state) when state.socket == socket do 130 | events = [:connection_closed] 131 | state = Enum.reduce(events, state, &handle_event/2) 132 | 133 | {:noreply, state} 134 | end 135 | 136 | @impl true 137 | def handle_info(:control_granted, state) do 138 | request_data(state) 139 | {:noreply, state} 140 | end 141 | 142 | @impl true 143 | def handle_info({:demand_data, how_many_buffers_demanded}, state) do 144 | state = finish_handshake(state) |> Map.replace!(:buffers_demanded, how_many_buffers_demanded) 145 | request_data(state) 146 | {:noreply, state} 147 | end 148 | 149 | @impl true 150 | def handle_info({:client_timeout, app, stream_key}, state) do 151 | if not state.published? do 152 | Logger.warning("No demand made for client /#{app}/#{stream_key}, terminating connection.") 153 | :gen_tcp.close(state.socket) 154 | end 155 | 156 | {:noreply, state} 157 | end 158 | 159 | @impl true 160 | def handle_info(other_msg, state) do 161 | handler_state = state.handler.handle_info(other_msg, state.handler_state) 162 | 163 | {:noreply, %{state | handler_state: handler_state}} 164 | end 165 | 166 | defp handle_data(data, state) do 167 | {messages, message_parser_state} = 168 | MessageParser.parse_packet_messages(data, state.message_parser_state) 169 | 170 | {message_handler_state, events} = 171 | MessageHandler.handle_client_messages(messages, state.message_handler_state) 172 | 173 | state = 174 | if message_handler_state.publish_msg != nil and not state.notified_about_client? do 175 | %{publish_msg: %Membrane.RTMP.Messages.Publish{stream_key: stream_key}} = 176 | message_handler_state 177 | 178 | if not is_function(state.handle_new_client) do 179 | raise "handle_new_client is not a function" 180 | end 181 | 182 | {handler_module, opts} = 183 | case state.handle_new_client.(self(), state.app, stream_key) do 184 | {handler_module, opts} -> {handler_module, opts} 185 | handler_module -> {handler_module, %{}} 186 | end 187 | 188 | Process.send_after( 189 | self(), 190 | {:client_timeout, state.app, stream_key}, 191 | Membrane.Time.as_milliseconds(state.client_timeout, :round) 192 | ) 193 | 194 | %{ 195 | state 196 | | notified_about_client?: true, 197 | handler: handler_module, 198 | handler_state: handler_module.handle_init(opts) 199 | } 200 | else 201 | state 202 | end 203 | 204 | state = Enum.reduce(events, state, &handle_event/2) 205 | 206 | state = 207 | if state.notified_about_client? && 208 | Kernel.function_exported?(state.handler, :handle_metadata, 2) do 209 | new_handler_state = 210 | Enum.reduce(messages, state.handler_state, fn 211 | {%Membrane.RTMP.Header{}, message}, handler_state 212 | when is_metadata_message(message) -> 213 | state.handler.handle_metadata({:metadata_message, message}, handler_state) 214 | 215 | _other, handler_state -> 216 | handler_state 217 | end) 218 | 219 | %{state | handler_state: new_handler_state} 220 | else 221 | state 222 | end 223 | 224 | request_data(state) 225 | 226 | {:noreply, 227 | %{ 228 | state 229 | | message_parser_state: message_parser_state, 230 | message_handler_state: message_handler_state 231 | }} 232 | end 233 | 234 | defp handle_event(event, state) do 235 | # call callbacks 236 | case event do 237 | :connection_closed -> 238 | case state.handler do 239 | nil -> 240 | state 241 | 242 | handler -> 243 | new_handler_state = handler.handle_connection_closed(state.handler_state) 244 | %{state | handler_state: new_handler_state} 245 | end 246 | 247 | :delete_stream -> 248 | new_handler_state = state.handler.handle_delete_stream(state.handler_state) 249 | %{state | handler_state: new_handler_state} 250 | 251 | {:set_chunk_size_required, chunk_size} -> 252 | new_message_parser_state = %{state.message_parser_state | chunk_size: chunk_size} 253 | %{state | message_parser_state: new_message_parser_state} 254 | 255 | {:data_available, payload} -> 256 | new_handler_state = 257 | state.handler.handle_data_available(payload, state.handler_state) 258 | 259 | %{ 260 | state 261 | | handler_state: new_handler_state, 262 | buffers_demanded: state.buffers_demanded - 1 263 | } 264 | 265 | {:connected, connected_msg} -> 266 | %{state | app: connected_msg.app} 267 | 268 | {:published, publish_msg} -> 269 | %{ 270 | state 271 | | stream_key: publish_msg.stream_key, 272 | published?: true 273 | } 274 | end 275 | end 276 | 277 | defp request_data(state) do 278 | if state.buffers_demanded > 0 or state.published? == false do 279 | if state.use_ssl? do 280 | :ssl.setopts(state.socket, active: :once) 281 | else 282 | :inet.setopts(state.socket, active: :once) 283 | end 284 | end 285 | end 286 | 287 | defp finish_handshake(state) when not state.published? do 288 | {message_handler_state, events} = 289 | MessageHandler.send_publish_success(state.message_handler_state) 290 | 291 | state = Enum.reduce(events, state, &handle_event/2) 292 | %{state | message_handler_state: message_handler_state} 293 | end 294 | 295 | defp finish_handshake(state), do: state 296 | end 297 | -------------------------------------------------------------------------------- /lib/membrane_rtmp_plugin/rtmp_server/listener.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMPServer.Listener do 2 | @moduledoc false 3 | 4 | # Module responsible for maintaining the listening socket. 5 | 6 | use Task 7 | require Logger 8 | alias Membrane.RTMPServer.ClientHandler 9 | 10 | @spec run( 11 | options :: %{ 12 | use_ssl?: boolean(), 13 | socket_module: :gen_tcp | :ssl, 14 | server: pid(), 15 | port: non_neg_integer() 16 | } 17 | ) :: no_return() 18 | def run(options) do 19 | options = Map.merge(options, %{socket_module: if(options.use_ssl?, do: :ssl, else: :gen_tcp)}) 20 | 21 | listen_options = 22 | if options.use_ssl? do 23 | certfile = System.get_env("CERT_PATH") 24 | keyfile = System.get_env("CERT_KEY_PATH") 25 | 26 | [ 27 | :binary, 28 | packet: :raw, 29 | active: false, 30 | certfile: certfile, 31 | keyfile: keyfile 32 | ] 33 | else 34 | [ 35 | :binary, 36 | packet: :raw, 37 | active: false 38 | ] 39 | end 40 | 41 | {:ok, socket} = options.socket_module.listen(options.port, listen_options) 42 | send(options.server, {:port, :inet.port(socket)}) 43 | 44 | accept_loop(socket, options) 45 | end 46 | 47 | defp accept_loop(socket, options) do 48 | {:ok, client} = :gen_tcp.accept(socket) 49 | 50 | {:ok, client_reference} = 51 | GenServer.start_link(ClientHandler, 52 | socket: client, 53 | use_ssl?: options.use_ssl?, 54 | server: options.server, 55 | handle_new_client: options.handle_new_client, 56 | client_timeout: options.client_timeout 57 | ) 58 | 59 | case :gen_tcp.controlling_process(client, client_reference) do 60 | :ok -> 61 | send(client_reference, :control_granted) 62 | 63 | {:error, reason} -> 64 | Logger.error( 65 | "Couldn't pass control to process: #{inspect(client_reference)} due to: #{inspect(reason)}" 66 | ) 67 | end 68 | 69 | accept_loop(socket, options) 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Mixfile do 2 | use Mix.Project 3 | 4 | @version "0.28.1" 5 | @github_url "https://github.com/membraneframework/membrane_rtmp_plugin" 6 | 7 | def project do 8 | [ 9 | app: :membrane_rtmp_plugin, 10 | version: @version, 11 | elixir: "~> 1.12", 12 | elixirc_paths: elixirc_paths(Mix.env()), 13 | compilers: [:unifex, :bundlex] ++ Mix.compilers() ++ maybe_add_rambo(), 14 | start_permanent: Mix.env() == :prod, 15 | deps: deps(), 16 | dialyzer: dialyzer(), 17 | 18 | # hex 19 | description: "RTMP Plugin for Membrane Multimedia Framework", 20 | package: package(), 21 | 22 | # docs 23 | name: "Membrane RTMP plugin", 24 | source_url: @github_url, 25 | homepage_url: "https://membraneframework.org", 26 | docs: docs() 27 | ] 28 | end 29 | 30 | def application do 31 | [ 32 | extra_applications: [:ssl] 33 | ] 34 | end 35 | 36 | defp elixirc_paths(:test), do: ["lib", "test/support"] 37 | defp elixirc_paths(_env), do: ["lib"] 38 | 39 | defp deps do 40 | [ 41 | {:membrane_core, "~> 1.0"}, 42 | {:unifex, "~> 1.2"}, 43 | {:membrane_precompiled_dependency_provider, "~> 0.1.0"}, 44 | {:membrane_h26x_plugin, "~> 0.10.0"}, 45 | {:membrane_h264_format, "~> 0.6.1"}, 46 | {:membrane_aac_plugin, "~> 0.19.0"}, 47 | {:membrane_flv_plugin, "~> 0.12.0"}, 48 | {:membrane_file_plugin, "~> 0.17.0"}, 49 | {:membrane_funnel_plugin, "~> 0.9.0"}, 50 | # testing 51 | {:membrane_hackney_plugin, "~> 0.11.0", only: :test}, 52 | {:ffmpex, "~> 0.11.0", only: :test}, 53 | {:membrane_stream_plugin, "~> 0.4.0", only: :test}, 54 | # development 55 | {:ex_doc, "~> 0.28", only: :dev, runtime: false}, 56 | {:dialyxir, "~> 1.1", only: :dev, runtime: false}, 57 | {:credo, "~> 1.6", only: :dev, runtime: false} 58 | ] 59 | end 60 | 61 | defp dialyzer() do 62 | opts = [ 63 | flags: [:error_handling] 64 | ] 65 | 66 | if System.get_env("CI") == "true" do 67 | # Store PLTs in cacheable directory for CI 68 | [plt_local_path: "priv/plts", plt_core_path: "priv/plts"] ++ opts 69 | else 70 | opts 71 | end 72 | end 73 | 74 | # for Mac M1 it is necessary to include rambo compiler (used by :ffmpex) 75 | def maybe_add_rambo() do 76 | if Mix.env() == :test, do: [:rambo], else: [] 77 | end 78 | 79 | defp package do 80 | [ 81 | maintainers: ["Membrane Team"], 82 | licenses: ["Apache-2.0"], 83 | links: %{ 84 | "GitHub" => @github_url, 85 | "Membrane Framework Homepage" => "https://membraneframework.org" 86 | }, 87 | files: ["lib", "mix.exs", "README*", "LICENSE*", ".formatter.exs", "bundlex.exs", "c_src"], 88 | exclude_patterns: [~r"c_src/.*/_generated.*"] 89 | ] 90 | end 91 | 92 | defp docs do 93 | [ 94 | main: "readme", 95 | extras: ["README.md", "LICENSE"], 96 | formatters: ["html"], 97 | source_ref: "v#{@version}", 98 | nest_modules_by_prefix: [Membrane.RTMP] 99 | ] 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bimap": {:hex, :bimap, "1.3.0", "3ea4832e58dc83a9b5b407c6731e7bae87458aa618e6d11d8e12114a17afa4b3", [:mix], [], "hexpm", "bf5a2b078528465aa705f405a5c638becd63e41d280ada41e0f77e6d255a10b4"}, 3 | "bunch": {:hex, :bunch, "1.6.1", "5393d827a64d5f846092703441ea50e65bc09f37fd8e320878f13e63d410aec7", [:mix], [], "hexpm", "286cc3add551628b30605efbe2fca4e38cc1bea89bcd0a1a7226920b3364fe4a"}, 4 | "bunch_native": {:hex, :bunch_native, "0.5.0", "8ac1536789a597599c10b652e0b526d8833348c19e4739a0759a2bedfd924e63", [:mix], [{:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "24190c760e32b23b36edeb2dc4852515c7c5b3b8675b1a864e0715bdd1c8f80d"}, 5 | "bundlex": {:hex, :bundlex, "1.5.3", "35d01e5bc0679510dd9a327936ffb518f63f47175c26a35e708cc29eaec0890b", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:elixir_uuid, "~> 1.2", [hex: :elixir_uuid, repo: "hexpm", optional: false]}, {:qex, "~> 0.5", [hex: :qex, repo: "hexpm", optional: false]}, {:req, ">= 0.4.0", [hex: :req, repo: "hexpm", optional: false]}, {:zarex, "~> 1.0", [hex: :zarex, repo: "hexpm", optional: false]}], "hexpm", "debd0eac151b404f6216fc60222761dff049bf26f7d24d066c365317650cd118"}, 6 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 7 | "castore": {:hex, :castore, "1.0.8", "dedcf20ea746694647f883590b82d9e96014057aff1d44d03ec90f36a5c0dc6e", [:mix], [], "hexpm", "0b2b66d2ee742cb1d9cb8c8be3b43c3a70ee8651f37b75a8b982e036752983f1"}, 8 | "certifi": {:hex, :certifi, "2.12.0", "2d1cca2ec95f59643862af91f001478c9863c2ac9cb6e2f89780bfd8de987329", [:rebar3], [], "hexpm", "ee68d85df22e554040cdb4be100f33873ac6051387baf6a8f6ce82272340ff1c"}, 9 | "coerce": {:hex, :coerce, "1.0.1", "211c27386315dc2894ac11bc1f413a0e38505d808153367bd5c6e75a4003d096", [:mix], [], "hexpm", "b44a691700f7a1a15b4b7e2ff1fa30bebd669929ac8aa43cffe9e2f8bf051cf1"}, 10 | "credo": {:hex, :credo, "1.7.7", "771445037228f763f9b2afd612b6aa2fd8e28432a95dbbc60d8e03ce71ba4446", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8bc87496c9aaacdc3f90f01b7b0582467b69b4bd2441fe8aae3109d843cc2f2e"}, 11 | "dialyxir": {:hex, :dialyxir, "1.4.3", "edd0124f358f0b9e95bfe53a9fcf806d615d8f838e2202a9f430d59566b6b53b", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "bf2cfb75cd5c5006bec30141b131663299c661a864ec7fbbc72dfa557487a986"}, 12 | "earmark_parser": {:hex, :earmark_parser, "1.4.41", "ab34711c9dc6212dda44fcd20ecb87ac3f3fce6f0ca2f28d4a00e4154f8cd599", [:mix], [], "hexpm", "a81a04c7e34b6617c2792e291b5a2e57ab316365c2644ddc553bb9ed863ebefa"}, 13 | "elixir_uuid": {:hex, :elixir_uuid, "1.2.1", "dce506597acb7e6b0daeaff52ff6a9043f5919a4c3315abb4143f0b00378c097", [:mix], [], "hexpm", "f7eba2ea6c3555cea09706492716b0d87397b88946e6380898c2889d68585752"}, 14 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 15 | "ex_doc": {:hex, :ex_doc, "0.34.2", "13eedf3844ccdce25cfd837b99bea9ad92c4e511233199440488d217c92571e8", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "5ce5f16b41208a50106afed3de6a2ed34f4acfd65715b82a0b84b49d995f95c1"}, 16 | "ffmpex": {:hex, :ffmpex, "0.11.0", "70d2e211a70e1d8cc1a81d73208d5efedda59d82db4c91160c79e5461529d291", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: false]}, {:rambo, "~> 0.3.0", [hex: :rambo, repo: "hexpm", optional: false]}], "hexpm", "2429d67badc91957ace572b9169615619740904a58791289ba54d99e57a164eb"}, 17 | "file_system": {:hex, :file_system, "1.0.1", "79e8ceaddb0416f8b8cd02a0127bdbababe7bf4a23d2a395b983c1f8b3f73edd", [:mix], [], "hexpm", "4414d1f38863ddf9120720cd976fce5bdde8e91d8283353f0e31850fa89feb9e"}, 18 | "finch": {:hex, :finch, "0.18.0", "944ac7d34d0bd2ac8998f79f7a811b21d87d911e77a786bc5810adb75632ada4", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "69f5045b042e531e53edc2574f15e25e735b522c37e2ddb766e15b979e03aa65"}, 19 | "hackney": {:hex, :hackney, "1.20.1", "8d97aec62ddddd757d128bfd1df6c5861093419f8f7a4223823537bad5d064e2", [:rebar3], [{:certifi, "~> 2.12.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "fe9094e5f1a2a2c0a7d10918fee36bfec0ec2a979994cff8cfe8058cd9af38e3"}, 20 | "hpax": {:hex, :hpax, "1.0.0", "28dcf54509fe2152a3d040e4e3df5b265dcb6cb532029ecbacf4ce52caea3fd2", [:mix], [], "hexpm", "7f1314731d711e2ca5fdc7fd361296593fc2542570b3105595bb0bc6d0fad601"}, 21 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 22 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 23 | "logger_backends": {:hex, :logger_backends, "1.0.0", "09c4fad6202e08cb0fbd37f328282f16539aca380f512523ce9472b28edc6bdf", [:mix], [], "hexpm", "1faceb3e7ec3ef66a8f5746c5afd020e63996df6fd4eb8cdb789e5665ae6c9ce"}, 24 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 25 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 26 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.1", "c7f58c120b2b5aa5fd80d540a89fdf866ed42f1f3994e4fe189abebeab610839", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "8a89a1eeccc2d798d6ea15496a6e4870b75e014d1af514b1b71fa33134f57814"}, 27 | "membrane_aac_format": {:hex, :membrane_aac_format, "0.8.0", "515631eabd6e584e0e9af2cea80471fee6246484dbbefc4726c1d93ece8e0838", [:mix], [{:bimap, "~> 1.1", [hex: :bimap, repo: "hexpm", optional: false]}], "hexpm", "a30176a94491033ed32be45e51d509fc70a5ee6e751f12fd6c0d60bd637013f6"}, 28 | "membrane_aac_plugin": {:hex, :membrane_aac_plugin, "0.19.0", "58a15efaaa4f2cc91b968464cfd269244e035efdd983aac2e3ddeb176fcf0585", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:membrane_aac_format, "~> 0.8.0", [hex: :membrane_aac_format, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "eb7e786e650608ee205f4eebff4c1df3677e545acf09802458f77f64f9942fe9"}, 29 | "membrane_core": {:hex, :membrane_core, "1.1.2", "3ca206893e1d3739a24d5092d21c06fcb4db326733a1798f9788fc53abb74829", [:mix], [{:bunch, "~> 1.6", [hex: :bunch, repo: "hexpm", optional: false]}, {:qex, "~> 0.3", [hex: :qex, repo: "hexpm", optional: false]}, {:ratio, "~> 3.0 or ~> 4.0", [hex: :ratio, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a989fd7e0516a7e66f5fb63950b1027315b7f8c8d82d8d685e178b0fb780901b"}, 30 | "membrane_file_plugin": {:hex, :membrane_file_plugin, "0.17.2", "650e134c2345d946f930082fac8bac9f5aba785a7817d38a9a9da41ffc56fa92", [:mix], [{:logger_backends, "~> 1.0", [hex: :logger_backends, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "df50c6040004cd7b901cf057bd7e99c875bbbd6ae574efc93b2c753c96f43b9d"}, 31 | "membrane_flv_plugin": {:hex, :membrane_flv_plugin, "0.12.0", "d715ad405af86dcaf4b2f479e34088e1f6738c7280366828e1066b39d2aa493a", [:mix], [{:membrane_aac_format, "~> 0.8.0", [hex: :membrane_aac_format, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_h264_format, "~> 0.6.1", [hex: :membrane_h264_format, repo: "hexpm", optional: false]}], "hexpm", "a317872d6d394e550c7bfd8979f12a3a1cc1e89b547d75360321025b403d3279"}, 32 | "membrane_funnel_plugin": {:hex, :membrane_funnel_plugin, "0.9.0", "9cfe09e44d65751f7d9d8d3c42e14797f7be69e793ac112ea63cd224af70a7bf", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "988790aca59d453a6115109f050699f7f45a2eb6a7f8dc5c96392760cddead54"}, 33 | "membrane_h264_format": {:hex, :membrane_h264_format, "0.6.1", "44836cd9de0abe989b146df1e114507787efc0cf0da2368f17a10c47b4e0738c", [:mix], [], "hexpm", "4b79be56465a876d2eac2c3af99e115374bbdc03eb1dea4f696ee9a8033cd4b0"}, 34 | "membrane_h265_format": {:hex, :membrane_h265_format, "0.2.0", "1903c072cf7b0980c4d0c117ab61a2cd33e88782b696290de29570a7fab34819", [:mix], [], "hexpm", "6df418bdf242c0d9f7dbf2e5aea4c2d182e34ac9ad5a8b8cef2610c290002e83"}, 35 | "membrane_h26x_plugin": {:hex, :membrane_h26x_plugin, "0.10.2", "caf2790d8c107df35f8d456b45f4e09fb9c56ce6c7669a3a03f7d59972e6ed82", [:mix], [{:bunch, "~> 1.4", [hex: :bunch, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:membrane_h264_format, "~> 0.6.0", [hex: :membrane_h264_format, repo: "hexpm", optional: false]}, {:membrane_h265_format, "~> 0.2.0", [hex: :membrane_h265_format, repo: "hexpm", optional: false]}], "hexpm", "becf1ac4a589adecd850137ccd61a33058f686083a514a7e39fcd721bcf9fb2e"}, 36 | "membrane_hackney_plugin": {:hex, :membrane_hackney_plugin, "0.11.0", "54b368333a23394e7cac2f4d6b701bf8c5ee6614670a31f4ebe009b5e691a5c1", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}, {:mockery, "~> 2.3", [hex: :mockery, repo: "hexpm", optional: false]}], "hexpm", "2b28fd1be3c889d5824d7d985598386c7673828c88f49a91221df3626af8a998"}, 37 | "membrane_precompiled_dependency_provider": {:hex, :membrane_precompiled_dependency_provider, "0.1.2", "8af73b7dc15ba55c9f5fbfc0453d4a8edfb007ade54b56c37d626be0d1189aba", [:mix], [{:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "7fe3e07361510445a29bee95336adde667c4162b76b7f4c8af3aeb3415292023"}, 38 | "membrane_stream_plugin": {:hex, :membrane_stream_plugin, "0.4.0", "0c4ab72a4e13bf0faa0f1166fbaf68d2e34167dbec345aedb74ce1eb7497bdda", [:mix], [{:membrane_core, "~> 1.0", [hex: :membrane_core, repo: "hexpm", optional: false]}], "hexpm", "5a9a9c17783e18ad740e6ddfed364581bdb7ebdab8e61ba2c19a1830356f7eb8"}, 39 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 40 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 41 | "mimerl": {:hex, :mimerl, "1.3.0", "d0cd9fc04b9061f82490f6581e0128379830e78535e017f7780f37fea7545726", [:rebar3], [], "hexpm", "a1e15a50d1887217de95f0b9b0793e32853f7c258a5cd227650889b38839fe9d"}, 42 | "mint": {:hex, :mint, "1.6.2", "af6d97a4051eee4f05b5500671d47c3a67dac7386045d87a904126fd4bbcea2e", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5ee441dffc1892f1ae59127f74afe8fd82fda6587794278d924e4d90ea3d63f9"}, 43 | "mockery": {:hex, :mockery, "2.3.3", "3dba87bd0422a513e6af6e0d811383f38f82ac6be5d3d285a5fcca9c299bd0ac", [:mix], [], "hexpm", "17282be00613286254298117cd25e607a39f15ac03b41c631f60e52f5b5ec974"}, 44 | "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, 45 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 46 | "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, 47 | "numbers": {:hex, :numbers, "5.2.4", "f123d5bb7f6acc366f8f445e10a32bd403c8469bdbce8ce049e1f0972b607080", [:mix], [{:coerce, "~> 1.0", [hex: :coerce, repo: "hexpm", optional: false]}, {:decimal, "~> 1.9 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "eeccf5c61d5f4922198395bf87a465b6f980b8b862dd22d28198c5e6fab38582"}, 48 | "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, 49 | "qex": {:hex, :qex, "0.5.1", "0d82c0f008551d24fffb99d97f8299afcb8ea9cf99582b770bd004ed5af63fd6", [:mix], [], "hexpm", "935a39fdaf2445834b95951456559e9dc2063d0a055742c558a99987b38d6bab"}, 50 | "rambo": {:hex, :rambo, "0.3.4", "8962ac3bd1a633ee9d0e8b44373c7913e3ce3d875b4151dcd060886092d2dce7", [:mix], [], "hexpm", "0cc54ed089fbbc84b65f4b8a774224ebfe60e5c80186fafc7910b3e379ad58f1"}, 51 | "ratio": {:hex, :ratio, "4.0.1", "3044166f2fc6890aa53d3aef0c336f84b2bebb889dc57d5f95cc540daa1912f8", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:numbers, "~> 5.2.0", [hex: :numbers, repo: "hexpm", optional: false]}], "hexpm", "c60cbb3ccdff9ffa56e7d6d1654b5c70d9f90f4d753ab3a43a6bf40855b881ce"}, 52 | "req": {:hex, :req, "0.5.6", "8fe1eead4a085510fe3d51ad854ca8f20a622aae46e97b302f499dfb84f726ac", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cfaa8e720945d46654853de39d368f40362c2641c4b2153c886418914b372185"}, 53 | "shmex": {:hex, :shmex, "0.5.1", "81dd209093416bf6608e66882cb7e676089307448a1afd4fc906c1f7e5b94cf4", [:mix], [{:bunch_native, "~> 0.5.0", [hex: :bunch_native, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.0", [hex: :bundlex, repo: "hexpm", optional: false]}], "hexpm", "c29f8286891252f64c4e1dac40b217d960f7d58def597c4e606ff8fbe71ceb80"}, 54 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, 55 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 56 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 57 | "unifex": {:hex, :unifex, "1.2.0", "90d1ec5e6d788350e07e474f7bd8b0ee866d6606beb9ca4e20dbb26328712a84", [:mix], [{:bunch, "~> 1.0", [hex: :bunch, repo: "hexpm", optional: false]}, {:bundlex, "~> 1.4", [hex: :bundlex, repo: "hexpm", optional: false]}, {:shmex, "~> 0.5.0", [hex: :shmex, repo: "hexpm", optional: false]}], "hexpm", "7a8395aabc3ba6cff04bbe5b995de7f899a38eb57f189e49927d6b8b6ccb6883"}, 58 | "zarex": {:hex, :zarex, "1.0.5", "58239e3ee5d75f343262bb4df5cf466555a1c689f920e5d3651a9333972f7c7e", [:mix], [], "hexpm", "9fb72ef0567c2b2742f5119a1ba8a24a2fabb21b8d09820aefbf3e592fa9a46a"}, 59 | } 60 | -------------------------------------------------------------------------------- /test/fixtures/audio.aac: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/membrane_rtmp_plugin/e903a7f5d31101a7db3687943bf116529b1e17d0/test/fixtures/audio.aac -------------------------------------------------------------------------------- /test/fixtures/audio.msr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/membrane_rtmp_plugin/e903a7f5d31101a7db3687943bf116529b1e17d0/test/fixtures/audio.msr -------------------------------------------------------------------------------- /test/fixtures/bun33s.flv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/membrane_rtmp_plugin/e903a7f5d31101a7db3687943bf116529b1e17d0/test/fixtures/bun33s.flv -------------------------------------------------------------------------------- /test/fixtures/bun33s_audio.flv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/membrane_rtmp_plugin/e903a7f5d31101a7db3687943bf116529b1e17d0/test/fixtures/bun33s_audio.flv -------------------------------------------------------------------------------- /test/fixtures/bun33s_video.flv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/membrane_rtmp_plugin/e903a7f5d31101a7db3687943bf116529b1e17d0/test/fixtures/bun33s_video.flv -------------------------------------------------------------------------------- /test/fixtures/testsrc.flv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/membrane_rtmp_plugin/e903a7f5d31101a7db3687943bf116529b1e17d0/test/fixtures/testsrc.flv -------------------------------------------------------------------------------- /test/fixtures/video.h264: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/membrane_rtmp_plugin/e903a7f5d31101a7db3687943bf116529b1e17d0/test/fixtures/video.h264 -------------------------------------------------------------------------------- /test/fixtures/video.msr: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/membraneframework/membrane_rtmp_plugin/e903a7f5d31101a7db3687943bf116529b1e17d0/test/fixtures/video.msr -------------------------------------------------------------------------------- /test/membrane_rtmp_plugin/rtmp_sink_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.SinkTest do 2 | use ExUnit.Case, async: true 3 | import Membrane.Testing.Assertions 4 | 5 | require Logger 6 | 7 | require Membrane.Pad 8 | 9 | alias Membrane.Pad 10 | alias Membrane.Testing.Pipeline 11 | 12 | @input_video_url "https://raw.githubusercontent.com/membraneframework/static/gh-pages/samples/big-buck-bunny/bun33s_480x270.h264" 13 | @input_audio_url "https://raw.githubusercontent.com/membraneframework/static/gh-pages/samples/big-buck-bunny/bun33s.aac" 14 | 15 | @rtmp_server_url "rtmp://localhost:49500/app/sink_test" 16 | @reference_flv_path "test/fixtures/bun33s.flv" 17 | @reference_flv_audio_path "test/fixtures/bun33s_audio.flv" 18 | @reference_flv_video_path "test/fixtures/bun33s_video.flv" 19 | 20 | setup ctx do 21 | flv_output_file = Path.join(ctx.tmp_dir, "rtmp_sink_test.flv") 22 | %{flv_output_file: flv_output_file} 23 | end 24 | 25 | @tag :tmp_dir 26 | test "Checks if audio and video are interleaved correctly", %{tmp_dir: tmp_dir} do 27 | output_file = Path.join(tmp_dir, "rtmp_sink_interleave_test.flv") 28 | 29 | rtmp_server = Task.async(fn -> start_rtmp_server(output_file) end) 30 | sink_pipeline_pid = start_interleaving_sink_pipeline(@rtmp_server_url) 31 | 32 | # There's an RC - there's no way to ensure RTMP server starts to listen before pipeline is started 33 | # so it may retry a few times before succeeding 34 | 35 | assert_start_of_stream(sink_pipeline_pid, :rtmp_sink, Pad.ref(:video, 0), 5_000) 36 | assert_start_of_stream(sink_pipeline_pid, :rtmp_sink, Pad.ref(:audio, 0)) 37 | 38 | assert_end_of_stream(sink_pipeline_pid, :rtmp_sink, Pad.ref(:video, 0), 5_000) 39 | assert_end_of_stream(sink_pipeline_pid, :rtmp_sink, Pad.ref(:audio, 0)) 40 | 41 | :ok = Pipeline.terminate(sink_pipeline_pid) 42 | # RTMP server should terminate when the connection is closed 43 | assert :ok = Task.await(rtmp_server) 44 | 45 | assert File.exists?(output_file) 46 | end 47 | 48 | @tag :tmp_dir 49 | test "Check if audio and video streams are correctly received by RTMP server instance", %{ 50 | flv_output_file: flv_output_file 51 | } do 52 | test_stream_correctly_received(flv_output_file, @reference_flv_path, [:audio, :video]) 53 | end 54 | 55 | @tag :tmp_dir 56 | test "Check if a single audio track is correctly received by RTMP server instance", %{ 57 | flv_output_file: flv_output_file 58 | } do 59 | test_stream_correctly_received(flv_output_file, @reference_flv_audio_path, [:audio]) 60 | end 61 | 62 | @tag :tmp_dir 63 | test "Check if a single video track is correctly received by RTMP server instance", %{ 64 | flv_output_file: flv_output_file 65 | } do 66 | test_stream_correctly_received(flv_output_file, @reference_flv_video_path, [:video]) 67 | end 68 | 69 | defp test_stream_correctly_received(flv_output, fixture, tracks) do 70 | rtmp_server = Task.async(fn -> start_rtmp_server(flv_output) end) 71 | 72 | sink_pipeline_pid = start_sink_pipeline(@rtmp_server_url, tracks) 73 | 74 | # There's an RC - there's no way to ensure RTMP server starts to listen before pipeline is started 75 | # so it may retry a few times before succeeding 76 | 77 | Enum.each( 78 | tracks, 79 | &assert_start_of_stream(sink_pipeline_pid, :rtmp_sink, Pad.ref(^&1, 0), 5_000) 80 | ) 81 | 82 | Enum.each( 83 | tracks, 84 | &assert_end_of_stream(sink_pipeline_pid, :rtmp_sink, Pad.ref(^&1, 0), 5_000) 85 | ) 86 | 87 | :ok = Pipeline.terminate(sink_pipeline_pid) 88 | # RTMP server should terminate when the connection is closed 89 | assert :ok = Task.await(rtmp_server) 90 | 91 | assert File.exists?(flv_output) 92 | assert File.stat!(flv_output).size == File.stat!(fixture).size 93 | end 94 | 95 | defp start_interleaving_sink_pipeline(rtmp_url) do 96 | import Membrane.ChildrenSpec 97 | 98 | options = [ 99 | spec: [ 100 | child(:rtmp_sink, %Membrane.RTMP.Sink{rtmp_url: rtmp_url, max_attempts: 5}), 101 | # 102 | child(:video_source, %Membrane.File.Source{location: "test/fixtures/video.msr"}) 103 | |> child(:video_deserializer, Membrane.Stream.Deserializer) 104 | |> child(:video_payloader, %Membrane.H264.Parser{ 105 | output_stream_structure: :avc1 106 | }) 107 | |> via_in(Pad.ref(:video, 0)) 108 | |> get_child(:rtmp_sink), 109 | # 110 | child(:audio_source, %Membrane.File.Source{location: "test/fixtures/audio.msr"}) 111 | |> child(:audio_deserializer, Membrane.Stream.Deserializer) 112 | |> child(:audio_parser, Membrane.AAC.Parser) 113 | |> via_in(Pad.ref(:audio, 0)) 114 | |> get_child(:rtmp_sink) 115 | ], 116 | test_process: self() 117 | ] 118 | 119 | Pipeline.start_link_supervised!(options) 120 | end 121 | 122 | defp start_sink_pipeline(rtmp_url, tracks) do 123 | import Membrane.ChildrenSpec 124 | 125 | options = [ 126 | spec: 127 | [ 128 | child(:rtmp_sink, %Membrane.RTMP.Sink{ 129 | rtmp_url: rtmp_url, 130 | tracks: tracks, 131 | max_attempts: 5 132 | }) 133 | ] ++ 134 | get_audio_elements(tracks) ++ 135 | get_video_elements(tracks), 136 | test_process: self() 137 | ] 138 | 139 | Pipeline.start_link_supervised!(options) 140 | end 141 | 142 | defp get_audio_elements(tracks) do 143 | import Membrane.ChildrenSpec 144 | 145 | if :audio in tracks do 146 | [ 147 | child(:audio_source, %Membrane.Hackney.Source{ 148 | location: @input_audio_url, 149 | hackney_opts: [follow_redirect: true] 150 | }) 151 | |> child(:audio_parser, %Membrane.AAC.Parser{ 152 | out_encapsulation: :none 153 | }) 154 | |> via_in(Pad.ref(:audio, 0)) 155 | |> get_child(:rtmp_sink) 156 | ] 157 | else 158 | [] 159 | end 160 | end 161 | 162 | defp get_video_elements(tracks) do 163 | import Membrane.ChildrenSpec 164 | 165 | if :video in tracks do 166 | [ 167 | child(:video_source, %Membrane.Hackney.Source{ 168 | location: @input_video_url, 169 | hackney_opts: [follow_redirect: true] 170 | }) 171 | |> child(:video_parser, %Membrane.H264.Parser{ 172 | output_stream_structure: :avc1, 173 | generate_best_effort_timestamps: %{framerate: {30, 1}} 174 | }) 175 | |> via_in(Pad.ref(:video, 0)) 176 | |> get_child(:rtmp_sink) 177 | ] 178 | else 179 | [] 180 | end 181 | end 182 | 183 | @spec start_rtmp_server(Path.t()) :: :ok | :error 184 | def start_rtmp_server(out_file) do 185 | import FFmpex 186 | use FFmpex.Options 187 | 188 | command = 189 | FFmpex.new_command() 190 | |> add_global_option(%FFmpex.Option{ 191 | name: "-listen", 192 | argument: "1", 193 | require_arg: true, 194 | contexts: [:global] 195 | }) 196 | |> add_input_file(@rtmp_server_url) 197 | |> add_file_option(option_f("flv")) 198 | |> add_output_file(out_file) 199 | |> add_file_option(option_c("copy")) 200 | 201 | case FFmpex.execute(command) do 202 | {:ok, _stdout} -> 203 | :ok 204 | 205 | {:error, {error, exit_code}} -> 206 | Logger.error( 207 | """ 208 | FFmpeg exited with a non-zero exit code (#{exit_code}) and the error message: 209 | #{error} 210 | """ 211 | |> String.trim() 212 | ) 213 | 214 | :error 215 | end 216 | end 217 | end 218 | -------------------------------------------------------------------------------- /test/membrane_rtmp_plugin/rtmp_source_bin_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.SourceBin.IntegrationTest do 2 | use ExUnit.Case, async: true 3 | 4 | import Membrane.Testing.Assertions 5 | 6 | require Logger 7 | 8 | alias Membrane.Testing 9 | 10 | @input_file "test/fixtures/testsrc.flv" 11 | @app "liveapp" 12 | @stream_key "ala2137" 13 | @default_port 22_224 14 | 15 | @stream_length_ms 3000 16 | @video_frame_duration_ms 42 17 | @audio_frame_duration_ms 24 18 | 19 | test "SourceBin outputs the correct number of audio and video buffers when the client connects to the given app and stream key" do 20 | self = self() 21 | 22 | pipeline_startup_task = 23 | Task.async(fn -> 24 | start_pipeline_with_external_rtmp_server(@app, @stream_key, self) 25 | end) 26 | 27 | port = 28 | receive do 29 | {:port, port} -> port 30 | end 31 | 32 | ffmpeg_task = 33 | Task.async(fn -> 34 | "rtmp://localhost:#{port}/#{@app}/#{@stream_key}" |> start_ffmpeg() 35 | end) 36 | 37 | pipeline = Task.await(pipeline_startup_task) 38 | 39 | assert_buffers(%{ 40 | pipeline: pipeline, 41 | sink: :video_sink, 42 | stream_length: @stream_length_ms, 43 | buffers_expected: div(@stream_length_ms, @video_frame_duration_ms) 44 | }) 45 | 46 | assert_buffers(%{ 47 | pipeline: pipeline, 48 | sink: :audio_sink, 49 | stream_length: @stream_length_ms, 50 | buffers_expected: div(@stream_length_ms, @audio_frame_duration_ms) 51 | }) 52 | 53 | assert_end_of_stream(pipeline, :audio_sink, :input) 54 | assert_end_of_stream(pipeline, :video_sink, :input) 55 | 56 | # Cleanup 57 | Testing.Pipeline.terminate(pipeline) 58 | assert :ok = Task.await(ffmpeg_task) 59 | end 60 | 61 | test "SourceBin doesn't output anything if the client tries to connect to different app or stream key" do 62 | {port, pipeline} = start_pipeline_with_builtin_rtmp_server(@app, @stream_key) 63 | other_app = "other_app" 64 | other_stream_key = "other_stream_key" 65 | 66 | ffmpeg_task = 67 | Task.async(fn -> 68 | "rtmp://localhost:#{port}/#{other_app}/#{other_stream_key}" |> start_ffmpeg() 69 | end) 70 | 71 | refute_sink_buffer(pipeline, :video_sink, _any) 72 | refute_sink_buffer(pipeline, :audio_sink, _any) 73 | 74 | # Cleanup 75 | Testing.Pipeline.terminate(pipeline) 76 | 77 | # Error is expected because connection will be refused and thus ffmpeg fails 78 | ffmpeg_result = Task.await(ffmpeg_task, 10_000) 79 | assert ffmpeg_result == :error 80 | end 81 | 82 | @tag :rtmps 83 | test "SourceBin allows for RTMPS connection" do 84 | self = self() 85 | 86 | pipeline_startup_task = 87 | Task.async(fn -> 88 | start_pipeline_with_external_rtmp_server(@app, @stream_key, self, 0, true) 89 | end) 90 | 91 | port = 92 | receive do 93 | {:port, port} -> port 94 | end 95 | 96 | ffmpeg_task = 97 | Task.async(fn -> 98 | "rtmps://localhost:#{port}/#{@app}/#{@stream_key}" |> start_ffmpeg() 99 | end) 100 | 101 | pipeline = Task.await(pipeline_startup_task) 102 | 103 | assert_buffers(%{ 104 | pipeline: pipeline, 105 | sink: :video_sink, 106 | stream_length: @stream_length_ms, 107 | buffers_expected: div(@stream_length_ms, @video_frame_duration_ms) 108 | }) 109 | 110 | assert_buffers(%{ 111 | pipeline: pipeline, 112 | sink: :audio_sink, 113 | stream_length: @stream_length_ms, 114 | buffers_expected: div(@stream_length_ms, @audio_frame_duration_ms) 115 | }) 116 | 117 | assert_end_of_stream(pipeline, :audio_sink, :input) 118 | assert_end_of_stream(pipeline, :video_sink, :input) 119 | 120 | # Cleanup 121 | Testing.Pipeline.terminate(pipeline) 122 | assert :ok = Task.await(ffmpeg_task) 123 | end 124 | 125 | # maximum timestamp in milliseconds 126 | @extended_timestamp_tag 0xFFFFFF 127 | test "SourceBin gracefully handles timestamp overflow" do 128 | # offset half a second in the past 129 | offset = @extended_timestamp_tag / 1_000 - 0.5 130 | 131 | self = self() 132 | 133 | pipeline_startup_task = 134 | Task.async(fn -> 135 | start_pipeline_with_external_rtmp_server(@app, @stream_key, self) 136 | end) 137 | 138 | port = 139 | receive do 140 | {:port, port} -> port 141 | end 142 | 143 | ffmpeg_task = 144 | Task.async(fn -> 145 | "rtmp://localhost:#{port}/#{@app}/#{@stream_key}" |> start_ffmpeg(ts_offset: offset) 146 | end) 147 | 148 | pipeline = Task.await(pipeline_startup_task) 149 | 150 | assert_buffers(%{ 151 | pipeline: pipeline, 152 | sink: :audio_sink, 153 | stream_length: trunc(@stream_length_ms + offset * 1_000), 154 | buffers_expected: div(@stream_length_ms, @audio_frame_duration_ms) 155 | }) 156 | 157 | assert_end_of_stream(pipeline, :audio_sink, :input, @stream_length_ms + 500) 158 | assert_end_of_stream(pipeline, :video_sink, :input) 159 | 160 | # Cleanup 161 | Testing.Pipeline.terminate(pipeline) 162 | assert :ok = Task.await(ffmpeg_task) 163 | end 164 | 165 | test "SourceBin gracefully handles start when starting after timestamp overflow" do 166 | # offset five seconds in the future 167 | offset = @extended_timestamp_tag / 1_000 + 5 168 | 169 | self = self() 170 | 171 | pipeline_startup_task = 172 | Task.async(fn -> 173 | start_pipeline_with_external_rtmp_server(@app, @stream_key, self) 174 | end) 175 | 176 | port = 177 | receive do 178 | {:port, port} -> port 179 | end 180 | 181 | ffmpeg_task = 182 | Task.async(fn -> 183 | "rtmp://localhost:#{port}/#{@app}/#{@stream_key}" |> start_ffmpeg(ts_offset: offset) 184 | end) 185 | 186 | pipeline = Task.await(pipeline_startup_task) 187 | 188 | assert_buffers(%{ 189 | pipeline: pipeline, 190 | sink: :audio_sink, 191 | stream_length: trunc(@stream_length_ms + offset * 1_000), 192 | buffers_expected: div(@stream_length_ms, @audio_frame_duration_ms) 193 | }) 194 | 195 | assert_end_of_stream(pipeline, :audio_sink, :input, @stream_length_ms) 196 | assert_end_of_stream(pipeline, :video_sink, :input) 197 | 198 | # Cleanup 199 | Testing.Pipeline.terminate(pipeline) 200 | assert :ok = Task.await(ffmpeg_task) 201 | end 202 | 203 | defp start_pipeline_with_builtin_rtmp_server(app, stream_key, use_ssl? \\ false) do 204 | options = [ 205 | module: Membrane.RTMP.Source.WithBuiltinServerTestPipeline, 206 | custom_args: %{app: app, stream_key: stream_key, port: @default_port, use_ssl?: use_ssl?}, 207 | test_process: self() 208 | ] 209 | 210 | pipeline_pid = Testing.Pipeline.start_link_supervised!(options) 211 | {@default_port, pipeline_pid} 212 | end 213 | 214 | defp start_pipeline_with_external_rtmp_server( 215 | app, 216 | stream_key, 217 | parent, 218 | port \\ 0, 219 | use_ssl? \\ false 220 | ) do 221 | parent_process_pid = self() 222 | 223 | handle_new_client = fn client_ref, app, stream_key -> 224 | send(parent_process_pid, {:client_ref, client_ref, app, stream_key}) 225 | Membrane.RTMP.Source.ClientHandlerImpl 226 | end 227 | 228 | {:ok, server_pid} = 229 | Membrane.RTMPServer.start_link( 230 | port: port, 231 | use_ssl?: use_ssl?, 232 | handle_new_client: handle_new_client, 233 | client_timeout: Membrane.Time.seconds(3) 234 | ) 235 | 236 | {:ok, assigned_port} = Membrane.RTMPServer.get_port(server_pid) 237 | 238 | send(parent, {:port, assigned_port}) 239 | 240 | {:ok, client_ref} = 241 | receive do 242 | {:client_ref, client_ref, ^app, ^stream_key} -> 243 | {:ok, client_ref} 244 | after 245 | 5000 -> :timeout 246 | end 247 | 248 | options = [ 249 | module: Membrane.RTMP.Source.WithExternalServerTestPipeline, 250 | custom_args: %{client_ref: client_ref}, 251 | test_process: parent 252 | ] 253 | 254 | {:ok, _supervisor, pid} = Testing.Pipeline.start_link(options) 255 | pid 256 | end 257 | 258 | defp start_ffmpeg(stream_url, opts \\ []) do 259 | import FFmpex 260 | use FFmpex.Options 261 | Logger.debug("Starting ffmpeg") 262 | 263 | command = 264 | FFmpex.new_command() 265 | |> add_global_option(option_y()) 266 | |> add_input_file(@input_file) 267 | |> add_file_option(option_re()) 268 | |> add_output_file(stream_url) 269 | |> add_file_option(option_f("flv")) 270 | |> add_file_option(option_vcodec("copy")) 271 | |> add_file_option(option_acodec("copy")) 272 | |> maybe_add_file_timestamps_offset(opts) 273 | 274 | {command, args} = FFmpex.prepare(command) 275 | 276 | case System.cmd(command, args) do 277 | {_output, 0} -> 278 | :ok 279 | 280 | {error, exit_code} -> 281 | Logger.error( 282 | """ 283 | FFmpeg exited with a non-zero exit code (#{exit_code}) and the error message: 284 | #{error} 285 | """ 286 | |> String.trim() 287 | ) 288 | 289 | :error 290 | end 291 | end 292 | 293 | defp maybe_add_file_timestamps_offset(command, options) do 294 | if ts_offset = Keyword.get(options, :ts_offset) do 295 | add_file_timestamps_offset(command, ts_offset) 296 | else 297 | command 298 | end 299 | end 300 | 301 | defp add_file_timestamps_offset(command, offset) do 302 | FFmpex.add_file_option(command, %FFmpex.Option{ 303 | name: "-output_ts_offset", 304 | argument: offset, 305 | contexts: [:output] 306 | }) 307 | end 308 | 309 | defp assert_buffers(%{last_dts: _dts} = state) do 310 | assert_sink_buffer(state.pipeline, state.sink, %Membrane.Buffer{dts: dts}) 311 | assert dts >= state.last_dts 312 | 313 | buffers = state.buffers + 1 314 | state = %{state | last_dts: dts, buffers: buffers} 315 | 316 | if dts < state.stream_length do 317 | assert_buffers(state) 318 | else 319 | assert state.buffers >= state.buffers_expected 320 | end 321 | end 322 | 323 | defp assert_buffers(state) do 324 | stream_length = Membrane.Time.milliseconds(state.stream_length) 325 | 326 | state 327 | |> Map.merge(%{stream_length: stream_length, last_dts: -1, buffers: 0}) 328 | |> assert_buffers() 329 | end 330 | end 331 | -------------------------------------------------------------------------------- /test/support/rtmp_source_with_builtin_server_test_pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Source.WithBuiltinServerTestPipeline do 2 | @moduledoc false 3 | use Membrane.Pipeline 4 | 5 | alias Membrane.RTMP.SourceBin 6 | alias Membrane.Testing 7 | 8 | @impl true 9 | def handle_init(_ctx, %{ 10 | app: app, 11 | stream_key: stream_key, 12 | port: port, 13 | use_ssl?: use_ssl? 14 | }) do 15 | protocol = if use_ssl?, do: "rtmps", else: "rtmp" 16 | 17 | structure = [ 18 | child(:src, %SourceBin{ 19 | url: "#{protocol}://localhost:#{port}/#{app}/#{stream_key}" 20 | }), 21 | child(:audio_sink, Testing.Sink), 22 | child(:video_sink, Testing.Sink), 23 | get_child(:src) |> via_out(:audio) |> get_child(:audio_sink), 24 | get_child(:src) |> via_out(:video) |> get_child(:video_sink) 25 | ] 26 | 27 | {[spec: structure], %{}} 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/support/rtmp_source_with_external_server_test_pipeline.ex: -------------------------------------------------------------------------------- 1 | defmodule Membrane.RTMP.Source.WithExternalServerTestPipeline do 2 | @moduledoc false 3 | use Membrane.Pipeline 4 | 5 | alias Membrane.RTMP.SourceBin 6 | alias Membrane.Testing 7 | 8 | @impl true 9 | def handle_init(_ctx, %{ 10 | client_ref: client_ref 11 | }) do 12 | structure = [ 13 | child(:src, %SourceBin{ 14 | client_ref: client_ref 15 | }), 16 | child(:audio_sink, Testing.Sink), 17 | child(:video_sink, Testing.Sink), 18 | get_child(:src) 19 | |> via_out(:audio) 20 | |> get_child(:audio_sink), 21 | get_child(:src) |> via_out(:video) |> get_child(:video_sink) 22 | ] 23 | 24 | {[spec: structure], %{}} 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start(capture_log: true, exclude: [:rtmps]) 2 | --------------------------------------------------------------------------------