diff --git a/Dockerfile b/Dockerfile index 962c0ec..aaac8b3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,7 @@ RUN apt-get clean && apt-get update && \ ENV GOPROXY=direct COPY . ./donut +COPY ./go-astiav/ /Users/leandro.moreira/src/go-astiav/ WORKDIR ${WD}/donut RUN go build . CMD ["/usr/src/app/donut/donut", "--enable-ice-mux=true"] \ No newline at end of file diff --git a/Makefile b/Makefile index 6f3b08b..6d6f3dd 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,19 @@ run: - # in case you need to re-build it, uncomment the following line - # docker compose stop && docker compose down && docker compose build && docker compose up origin srt app - docker compose stop && docker compose build app && docker compose up origin srt app + docker compose stop && docker compose up origin srt app -test: - # in case you need to re-build it, uncomment the following line - # docker compose stop test && docker compose down test && docker compose build test && docker compose run --rm test - docker compose stop test && docker compose down test && docker compose run --rm test +run-dev: + docker compose stop && docker compose down && docker compose build app && docker compose up origin srt app + +run-dev-total-rebuild: + docker compose stop && docker compose down && docker compose build && docker compose up origin srt app + +clean-docker: + docker-compose down -v --rmi all --remove-orphans && docker volume prune -a -f && docker system prune -a -f && docker builder prune -a -f run-srt: docker compose stop && docker compose down && docker compose build srt && docker compose up srt -mac-run-local: - ./scripts/mac_local_run.sh - -mac-test-local: - ./scripts/mac_local_run_test.sh - -html-local-coverage: - go tool cover -html=coverage.out - lint: docker compose stop lint && docker compose down lint && docker compose run --rm lint -# INCOMPLETE from https://github.com/asticode/go-astiav/blob/master/Makefile -install-ffmpeg: - ./scripts/install_local_ffmpeg.sh - .PHONY: run lint test run-srt mac-run-local mac-test-local html-local-coverage install-ffmpeg diff --git a/docker-compose.yaml b/docker-compose.yaml index 4e5bebf..c4368ae 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -13,8 +13,10 @@ services: - "6060:6060" depends_on: - srt + - rtmp links: - srt + - rtmp test: build: @@ -36,6 +38,19 @@ services: - "./:/app/" command: "golangci-lint run -v" + rtmp: # simulating an RTMP live transmission + image: jrottenberg/ffmpeg:4.4-alpine + entrypoint: sh + command: "/scripts/ffmpeg_rtmp.sh" + volumes: + - "./scripts:/scripts" + - "./fonts/0xProto:/usr/share/fonts" + environment: + - RTMP_HOST=0.0.0.0 + - RTMP_PORT=1935 + ports: + - "1935:1935" + srt: build: context: . @@ -62,8 +77,8 @@ services: command: "/scripts/ffmpeg_mpegts_udp.sh" volumes: - "./scripts:/scripts" + - "./fonts/0xProto:/usr/share/fonts" environment: - SRT_INPUT_HOST=srt - SRT_INPUT_PORT=1234 - PKT_SIZE=1316 - diff --git a/fonts/0xProto/0xProtoNerdFont-Regular.ttf b/fonts/0xProto/0xProtoNerdFont-Regular.ttf new file mode 100644 index 0000000..5089bb4 Binary files /dev/null and b/fonts/0xProto/0xProtoNerdFont-Regular.ttf differ diff --git a/fonts/0xProto/0xProtoNerdFontMono-Regular.ttf b/fonts/0xProto/0xProtoNerdFontMono-Regular.ttf new file mode 100644 index 0000000..b642c07 Binary files /dev/null and b/fonts/0xProto/0xProtoNerdFontMono-Regular.ttf differ diff --git a/fonts/0xProto/0xProtoNerdFontPropo-Regular.ttf b/fonts/0xProto/0xProtoNerdFontPropo-Regular.ttf new file mode 100644 index 0000000..7fd6afa Binary files /dev/null and b/fonts/0xProto/0xProtoNerdFontPropo-Regular.ttf differ diff --git a/fonts/0xProto/LICENSE b/fonts/0xProto/LICENSE new file mode 100644 index 0000000..fefebe8 --- /dev/null +++ b/fonts/0xProto/LICENSE @@ -0,0 +1,92 @@ +Copyright (c) 2023, 0xType Project Authors (https://github.com/0xType) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/fonts/0xProto/README.md b/fonts/0xProto/README.md new file mode 100644 index 0000000..f85d010 --- /dev/null +++ b/fonts/0xProto/README.md @@ -0,0 +1,48 @@ +# Nerd Fonts + +This is an archived font from the Nerd Fonts release v3.2.1. + +For more information see: +* https://github.com/ryanoasis/nerd-fonts/ +* https://github.com/ryanoasis/nerd-fonts/releases/latest/ + +# 0xProto + +An opinionated font for software engineers. + +For more information have a look at the upstream website: https://github.com/0xType/0xProto + +Version: 1.603 + +## Which font? + +### TL;DR + +* Pick your font family: + * If you are limited to monospaced fonts (because of your terminal, etc) then pick a font with `Nerd Font Mono` (or `NFM`). + * If you want to have bigger icons (usually around 1.5 normal letters wide) pick a font without `Mono` i.e. `Nerd Font` (or `NF`). Most terminals support this, but ymmv. + * If you work in a proportional context (GUI elements or edit a presentation etc) pick a font with `Nerd Font Propo` (or `NFP`). + +### Ligatures + +Ligatures are generally preserved in the patched fonts. +Nerd Fonts `v2.0.0` had no ligatures in the `Nerd Font Mono` fonts, this has been dropped with `v2.1.0`. +If you have a ligature-aware terminal and don't want ligatures you can (usually) disable them in the terminal settings. + +### Explanation + +Once you narrow down your font choice of family (`Droid Sans`, `Inconsolata`, etc) and style (`bold`, `italic`, etc) you have 2 main choices: + +#### `Option 1: Download already patched font` + + * For a stable version download a font package from the [release page](https://github.com/ryanoasis/nerd-fonts/releases) + * Direct links for [0xProto.zip](https://github.com/ryanoasis/nerd-fonts/releases/latest/download/0xProto.zip) or [0xProto.tar.xz](https://github.com/ryanoasis/nerd-fonts/releases/latest/download/0xProto.tar.xz) + +#### `Option 2: Patch your own font` + + * Patch your own variations with the various options provided by the font patcher (i.e. not include all symbols for smaller font size) + +For more information see: [The FAQ](https://github.com/ryanoasis/nerd-fonts/wiki/FAQ-and-Troubleshooting#which-font) + +[SIL-RFN]:http://scripts.sil.org/cms/scripts/page.php?item_id=OFL_web_fonts_and_RFNs#14cbfd4a + diff --git a/go-astiav b/go-astiav new file mode 160000 index 0000000..338c4df --- /dev/null +++ b/go-astiav @@ -0,0 +1 @@ +Subproject commit 338c4dfccaa8d32925aaea48f5d87ecf4637a2de diff --git a/go.mod b/go.mod index 7bb4c57..1805af4 100644 --- a/go.mod +++ b/go.mod @@ -2,9 +2,11 @@ module github.com/flavioribeiro/donut go 1.19 +replace github.com/asticode/go-astiav => /Users/leandro.moreira/src/go-astiav + require ( github.com/asticode/go-astiav v0.12.0 - github.com/asticode/go-astikit v0.36.0 + github.com/asticode/go-astikit v0.42.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/pion/webrtc/v3 v3.1.47 github.com/stretchr/testify v1.8.0 diff --git a/go.sum b/go.sum index 4f0b6d9..63dd8a3 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,5 @@ -github.com/asticode/go-astiav v0.12.0 h1:tETfPhVpJrSyh3zvUOmDvebFaCoFpeATSaQAA7B50J8= -github.com/asticode/go-astiav v0.12.0/go.mod h1:phvUnSSlV91S/PELeLkDisYiRLOssxWOsj4oDrqM/54= -github.com/asticode/go-astikit v0.36.0 h1:WHSY88YT76D/XRbdp0lMLwfjyUGw8dygnbKKtbGNIG8= -github.com/asticode/go-astikit v0.36.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= +github.com/asticode/go-astikit v0.42.0 h1:pnir/2KLUSr0527Tv908iAH6EGYYrYta132vvjXsH5w= +github.com/asticode/go-astikit v0.42.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/controllers/engine/donut_engine_controller.go b/internal/controllers/engine/donut_engine_controller.go index a84e5ff..78ab099 100644 --- a/internal/controllers/engine/donut_engine_controller.go +++ b/internal/controllers/engine/donut_engine_controller.go @@ -2,6 +2,7 @@ package engine import ( "fmt" + "strings" "github.com/flavioribeiro/donut/internal/controllers/probers" "github.com/flavioribeiro/donut/internal/controllers/streamers" @@ -11,10 +12,10 @@ import ( ) type DonutEngine interface { - Appetizer() entities.DonutAppetizer + Appetizer() (entities.DonutAppetizer, error) ServerIngredients() (*entities.StreamInfo, error) ClientIngredients() (*entities.StreamInfo, error) - RecipeFor(server, client *entities.StreamInfo) *entities.DonutRecipe + RecipeFor(server, client *entities.StreamInfo) (*entities.DonutRecipe, error) Serve(p *entities.DonutParameters) } @@ -80,7 +81,11 @@ type donutEngine struct { } func (d *donutEngine) ServerIngredients() (*entities.StreamInfo, error) { - return d.prober.StreamInfo(d.Appetizer()) + appetizer, err := d.Appetizer() + if err != nil { + return nil, err + } + return d.prober.StreamInfo(appetizer) } func (d *donutEngine) ClientIngredients() (*entities.StreamInfo, error) { @@ -91,7 +96,7 @@ func (d *donutEngine) Serve(p *entities.DonutParameters) { d.streamer.Stream(p) } -func (d *donutEngine) RecipeFor(server, client *entities.StreamInfo) *entities.DonutRecipe { +func (d *donutEngine) RecipeFor(server, client *entities.StreamInfo) (*entities.DonutRecipe, error) { // TODO: implement proper matching // // suggestions: @@ -101,10 +106,16 @@ func (d *donutEngine) RecipeFor(server, client *entities.StreamInfo) *entities.D // preferable = [vp8, opus] // if union(preferable, client.medias) // transcode, preferable + appetizer, err := d.Appetizer() + if err != nil { + return nil, err + } + r := &entities.DonutRecipe{ - Input: d.Appetizer(), + Input: appetizer, Video: entities.DonutMediaTask{ - Action: entities.DonutBypass, + // Action: entities.DonutBypass, + Action: entities.DonutTranscode, Codec: entities.H264, }, Audio: entities.DonutMediaTask{ @@ -122,18 +133,29 @@ func (d *donutEngine) RecipeFor(server, client *entities.StreamInfo) *entities.D }, } - return r + return r, nil } -func (d *donutEngine) Appetizer() entities.DonutAppetizer { - // TODO: implement input based on param to build proper SRT/RTMP/etc - return entities.DonutAppetizer{ - URL: fmt.Sprintf("srt://%s:%d", d.req.SRTHost, d.req.SRTPort), - Format: "mpegts", // it'll change based on input, i.e. rmtp flv - Options: map[entities.DonutInputOptionKey]string{ - entities.DonutSRTStreamID: d.req.SRTStreamID, - entities.DonutSRTTranstype: "live", - entities.DonutSRTsmoother: "live", - }, +func (d *donutEngine) Appetizer() (entities.DonutAppetizer, error) { + if strings.Contains(strings.ToLower(d.req.StreamURL), "rtmp") { + return entities.DonutAppetizer{ + URL: fmt.Sprintf("%s/%s", d.req.StreamURL, d.req.StreamID), + Options: map[entities.DonutInputOptionKey]string{ + entities.DonutRTMPLive: "live", + }, + // Format: "flv", + }, nil + } else if strings.Contains(strings.ToLower(d.req.StreamURL), "srt") { + return entities.DonutAppetizer{ + URL: d.req.StreamURL, + Format: "mpegts", // TODO: check how to get format for srt + Options: map[entities.DonutInputOptionKey]string{ + entities.DonutSRTStreamID: d.req.StreamID, + entities.DonutSRTTranstype: "live", + entities.DonutSRTsmoother: "live", + }, + }, nil } + + return entities.DonutAppetizer{}, entities.ErrUnsupportedStreamURL } diff --git a/internal/controllers/probers/libav_ffmpeg.go b/internal/controllers/probers/libav_ffmpeg.go index b461f5e..f8305fb 100644 --- a/internal/controllers/probers/libav_ffmpeg.go +++ b/internal/controllers/probers/libav_ffmpeg.go @@ -2,6 +2,7 @@ package probers import ( "fmt" + "strings" "github.com/asticode/go-astiav" "github.com/asticode/go-astikit" @@ -39,7 +40,10 @@ func NewLibAVFFmpeg( // Match returns true when the request is for an LibAVFFmpeg prober func (c *LibAVFFmpeg) Match(req *entities.RequestParams) bool { - return req.SRTHost != "" + isRTMP := strings.Contains(strings.ToLower(req.StreamURL), "rtmp") + isSRT := strings.Contains(strings.ToLower(req.StreamURL), "srt") + + return isRTMP || isSRT } // StreamInfo connects to the SRT stream to discovery media properties. @@ -60,7 +64,7 @@ func (c *LibAVFFmpeg) StreamInfo(req entities.DonutAppetizer) (*entities.StreamI inputOptions := c.defineInputOptions(req.Options, closer) if err := inputFormatContext.OpenInput(req.URL, inputFormat, inputOptions); err != nil { - return nil, fmt.Errorf("error while inputFormatContext.OpenInput: %w", err) + return nil, fmt.Errorf("error while inputFormatContext.OpenInput: (%s, %#v, %#v) %w", req.URL, inputFormat, inputOptions, err) } closer.Add(inputFormatContext.CloseInput) diff --git a/internal/controllers/streamers/libav_ffmpeg.go b/internal/controllers/streamers/libav_ffmpeg.go index a60727a..cbff84b 100644 --- a/internal/controllers/streamers/libav_ffmpeg.go +++ b/internal/controllers/streamers/libav_ffmpeg.go @@ -50,7 +50,10 @@ func NewLibAVFFmpegStreamer(p LibAVFFmpegStreamerParams) ResultLibAVFFmpegStream } func (c *LibAVFFmpegStreamer) Match(req *entities.RequestParams) bool { - return req.SRTHost != "" + isRTMP := strings.Contains(strings.ToLower(req.StreamURL), "rtmp") + isSRT := strings.Contains(strings.ToLower(req.StreamURL), "srt") + + return isRTMP || isSRT } type streamContext struct { @@ -78,7 +81,7 @@ type libAVParams struct { } func (c *LibAVFFmpegStreamer) Stream(donut *entities.DonutParameters) { - c.l.Infow("streaming has started") + c.l.Infof("streaming has started for %#v", donut) closer := astikit.NewCloser() defer closer.Close() @@ -89,20 +92,26 @@ func (c *LibAVFFmpegStreamer) Stream(donut *entities.DonutParameters) { // it's useful for debugging astiav.SetLogLevel(astiav.LogLevelDebug) - astiav.SetLogCallback(func(l astiav.LogLevel, fmt, msg, parent string) { + astiav.SetLogCallback(func(_ astiav.Classer, l astiav.LogLevel, fmt, msg string) { c.l.Infof("ffmpeg %s: - %s", c.libAVLogToString(l), strings.TrimSpace(msg)) }) + // 138.1 internal/controllers/streamers/libav_ffmpeg.go:95:24: + // cannot use func(l astiav.LogLevel, fmt, msg, parent string) {…} + // (value of type func(l astiav.LogLevel, fmt string, msg string, parent string)) as astiav.LogCallback value in argument to astiav.SetLogCallback + c.l.Infof("preparing input") if err := c.prepareInput(p, closer, donut); err != nil { c.onError(err, donut) return } + c.l.Infof("preparing output") if err := c.prepareOutput(p, closer, donut); err != nil { c.onError(err, donut) return } + c.l.Infof("preparing filters") if err := c.prepareFilters(p, closer, donut); err != nil { c.onError(err, donut) return @@ -121,6 +130,7 @@ func (c *LibAVFFmpegStreamer) Stream(donut *entities.DonutParameters) { c.onError(donut.Ctx.Err(), donut) return default: + c.l.Infof("started reading frame") if err := p.inputFormatContext.ReadFrame(inPkt); err != nil { if errors.Is(err, astiav.ErrEof) { c.l.Info("streaming has ended") @@ -141,6 +151,63 @@ func (c *LibAVFFmpegStreamer) Stream(donut *entities.DonutParameters) { isVideoBypass := donut.Recipe.Video.Action == entities.DonutBypass if isVideo && isVideoBypass { if donut.OnVideoFrame != nil { + // The SRT(mpegts[h264]) bitstream format is Annex B 0x0, 0x0, 0x0, 0x1 [Start Code] + // [start code]--[NAL]--[start code]--[NAL] etc + // + // The RTMP(flv[h264]) bitstream format is AVCC (mp4) 0xY, 0xZ, 0xK, 0xW [Length] + // [SIZE (4 bytes)]--[NAL]--[SIZE (4 bytes)]--[NAL] etc + // + // ref: https://stackoverflow.com/questions/28421375/usage-of-start-code-for-h264-video/29103276#29103276 + // + // To convert from AVCC to AnnexB: + // + // Remove length, insert start code, insert SPS for each I-frame, insert PPS for each frame, insert AU delimiter for each GOP. + // + // https://ffmpeg.org/doxygen/trunk/h264__mp4toannexb__bsf_8c.html#a773e34981d7642d499348d1ae72fd02e + + // av_bsf_send_packet(bsfContext, pkt) + // av_bsf_receive_packet(bsfContext, pkt) + + // for { + // c.l.Infof("start receiving packet") + // if err := s.decCodecContext.ReceiveFrame(s.decFrame); err != nil { + // if errors.Is(err, astiav.ErrEof) || errors.Is(err, astiav.ErrEagain) { + // break + // } + // c.onError(err, donut) + // return + // } + // c.l.Infof("start filtering") + // if err := c.filterAndEncode(s.decFrame, s, donut); err != nil { + // c.onError(err, donut) + // return + // } + // } + + bistreamFilter := astiav.FindBitStreamFilterByName("h264_mp4toannexb") + if bistreamFilter == nil { + c.l.Info("cannot find bit stream filter") + return + } + bsfCtx, err := astiav.AllocBitStreamContext(bistreamFilter) + if err != nil { + c.l.Info("error while AllocBitStreamContext", err) + return + } + if err := bsfCtx.Init(); err != nil { + c.l.Info("error while init", err) + return + } + if err := bsfCtx.SendPacket(inPkt); err != nil { + c.l.Info("error while SendPacket", err) + return + } + + if bsfCtx.ReceivePacket(inPkt) != nil { + c.l.Info("error while ReceivePacket", err) + return + } + if err := donut.OnVideoFrame(inPkt.Data(), entities.MediaFrameContext{ PTS: int(inPkt.Pts()), DTS: int(inPkt.Dts()), @@ -169,12 +236,19 @@ func (c *LibAVFFmpegStreamer) Stream(donut *entities.DonutParameters) { continue } + if isAudio { + continue + } + + c.l.Infof("start sending packet") + // c.processPacket(inPkt, s, donut) if err := s.decCodecContext.SendPacket(inPkt); err != nil { c.onError(err, donut) return } for { + c.l.Infof("start receiving packet") if err := s.decCodecContext.ReceiveFrame(s.decFrame); err != nil { if errors.Is(err, astiav.ErrEof) || errors.Is(err, astiav.ErrEagain) { break @@ -182,7 +256,7 @@ func (c *LibAVFFmpegStreamer) Stream(donut *entities.DonutParameters) { c.onError(err, donut) return } - + c.l.Infof("start filtering") if err := c.filterAndEncode(s.decFrame, s, donut); err != nil { c.onError(err, donut) return @@ -414,8 +488,6 @@ func (c *LibAVFFmpegStreamer) prepareFilters(p *libAVParams, closer *astikit.Clo closer.Add(inputs.Free) if s.decCodecContext.MediaType() == astiav.MediaTypeAudio { - // TODO: what's the difference between args and content? - // why args are necessary? args = astiav.FilterArgs{ "channel_layout": s.decCodecContext.ChannelLayout().String(), "sample_fmt": s.decCodecContext.SampleFormat().Name(), @@ -483,6 +555,29 @@ func (c *LibAVFFmpegStreamer) prepareFilters(p *libAVParams, closer *astikit.Clo return nil } +func (c *LibAVFFmpegStreamer) processPacket(pkt *astiav.Packet, s *streamContext, donut *entities.DonutParameters) { + if err := s.decCodecContext.SendPacket(pkt); err != nil { + c.onError(err, donut) + return + } + + for { + c.l.Infof("start receiving packet") + if err := s.decCodecContext.ReceiveFrame(s.decFrame); err != nil { + if errors.Is(err, astiav.ErrEof) || errors.Is(err, astiav.ErrEagain) { + break + } + c.onError(err, donut) + return + } + c.l.Infof("start filtering") + if err := c.filterAndEncode(s.decFrame, s, donut); err != nil { + c.onError(err, donut) + return + } + } +} + func (c *LibAVFFmpegStreamer) filterAndEncode(f *astiav.Frame, s *streamContext, donut *entities.DonutParameters) (err error) { if err = s.buffersrcContext.BuffersrcAddFrame(f, astiav.NewBuffersrcFlags(astiav.BuffersrcFlagKeepRef)); err != nil { return fmt.Errorf("adding frame failed: %w", err) @@ -499,7 +594,7 @@ func (c *LibAVFFmpegStreamer) filterAndEncode(f *astiav.Frame, s *streamContext, } // TODO: should we avoid setting the picture type for audio? s.filterFrame.SetPictureType(astiav.PictureTypeNone) - + c.l.Infof("start encoding") if err = c.encodeFrame(s.filterFrame, s, donut); err != nil { err = fmt.Errorf("main: encoding and writing frame failed: %w", err) return @@ -520,6 +615,7 @@ func (c *LibAVFFmpegStreamer) encodeFrame(f *astiav.Frame, s *streamContext, don } for { + c.l.Infof("start receiving packet") if err = s.encCodecContext.ReceivePacket(s.encPkt); err != nil { if errors.Is(err, astiav.ErrEof) || errors.Is(err, astiav.ErrEagain) { err = nil @@ -535,6 +631,7 @@ func (c *LibAVFFmpegStreamer) encodeFrame(f *astiav.Frame, s *streamContext, don isVideo := s.decCodecContext.MediaType() == astiav.MediaTypeVideo if isVideo { if donut.OnVideoFrame != nil { + c.l.Infof("sending transcoded video") if err := donut.OnVideoFrame(s.encPkt.Data(), entities.MediaFrameContext{ PTS: int(s.encPkt.Pts()), DTS: int(s.encPkt.Dts()), @@ -548,6 +645,7 @@ func (c *LibAVFFmpegStreamer) encodeFrame(f *astiav.Frame, s *streamContext, don isAudio := s.decCodecContext.MediaType() == astiav.MediaTypeAudio if isAudio { if donut.OnAudioFrame != nil { + c.l.Infof("sending transcoded audio") if err := donut.OnAudioFrame(s.encPkt.Data(), entities.MediaFrameContext{ PTS: int(s.encPkt.Pts()), DTS: int(s.encPkt.Dts()), diff --git a/internal/controllers/webrtc_controller.go b/internal/controllers/webrtc_controller.go index d4555ce..b5e820a 100644 --- a/internal/controllers/webrtc_controller.go +++ b/internal/controllers/webrtc_controller.go @@ -42,14 +42,14 @@ func (c *WebRTCController) Setup(cancel context.CancelFunc, donutRecipe *entitie response.Connection = peer var videoTrack *webrtc.TrackLocalStaticSample - videoTrack, err = c.CreateTrack(peer, donutRecipe.Video.Codec, string(entities.VideoType), params.SRTStreamID) + videoTrack, err = c.CreateTrack(peer, donutRecipe.Video.Codec, string(entities.VideoType), params.StreamID) if err != nil { return nil, err } response.Video = videoTrack var audioTrack *webrtc.TrackLocalStaticSample - audioTrack, err = c.CreateTrack(peer, donutRecipe.Audio.Codec, string(entities.AudioType), params.SRTStreamID) + audioTrack, err = c.CreateTrack(peer, donutRecipe.Audio.Codec, string(entities.AudioType), params.StreamID) if err != nil { return nil, err } diff --git a/internal/entities/entities.go b/internal/entities/entities.go index 00d39ea..fb4388f 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -3,6 +3,7 @@ package entities import ( "context" "fmt" + "strings" "time" "github.com/asticode/go-astiav" @@ -27,6 +28,9 @@ type RequestParams struct { SRTPort uint16 `json:",string"` SRTStreamID string Offer webrtc.SessionDescription + + StreamURL string + StreamID string } func (p *RequestParams) Valid() error { @@ -34,16 +38,18 @@ func (p *RequestParams) Valid() error { return ErrMissingParamsOffer } - if p.SRTHost == "" { - return ErrMissingSRTHost + if p.StreamID == "" { + return ErrMissingStreamID } - if p.SRTPort == 0 { - return ErrMissingSRTPort + if p.StreamURL == "" { + return ErrMissingStreamURL } + isRTMP := strings.Contains(strings.ToLower(p.StreamURL), "rtmp") + isSRT := strings.Contains(strings.ToLower(p.StreamURL), "srt") - if p.SRTStreamID == "" { - return ErrMissingSRTStreamID + if !(isRTMP || isSRT) { + return ErrUnsupportedStreamURL } return nil @@ -53,7 +59,7 @@ func (p *RequestParams) String() string { if p == nil { return "" } - return fmt.Sprintf("ParamsOffer %v:%v/%v", p.SRTHost, p.SRTPort, p.SRTStreamID) + return fmt.Sprintf("RequestParams {StreamURL: %s, StreamID: %s}", p.StreamURL, p.StreamID) } type MessageType string @@ -175,6 +181,8 @@ var DonutSRTStreamID DonutInputOptionKey = "srt_streamid" var DonutSRTsmoother DonutInputOptionKey = "smoother" var DonutSRTTranstype DonutInputOptionKey = "transtype" +var DonutRTMPLive DonutInputOptionKey = "rtmp_live" + type DonutInputFormat string func (d DonutInputFormat) String() string { diff --git a/internal/entities/errors.go b/internal/entities/errors.go index 2d12efe..9583915 100644 --- a/internal/entities/errors.go +++ b/internal/entities/errors.go @@ -9,6 +9,10 @@ var ErrHTTPGetOnly = errors.New("you must use http GET verb") var ErrHTTPPostOnly = errors.New("you must use http POST verb") var ErrMissingParamsOffer = errors.New("ParamsOffer must not be nil") +var ErrMissingStreamURL = errors.New("stream URL must not be nil") +var ErrMissingStreamID = errors.New("stream ID must not be nil") +var ErrUnsupportedStreamURL = errors.New("unsupported stream") + var ErrMissingSRTHost = errors.New("SRTHost must not be nil") var ErrMissingSRTPort = errors.New("SRTPort must be valid") var ErrMissingSRTStreamID = errors.New("SRTStreamID must not be empty") diff --git a/internal/web/handlers/signaling.go b/internal/web/handlers/signaling.go index 3707bb3..68ba5cc 100644 --- a/internal/web/handlers/signaling.go +++ b/internal/web/handlers/signaling.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "net/http" + "time" "github.com/flavioribeiro/donut/internal/controllers" "github.com/flavioribeiro/donut/internal/controllers/engine" @@ -41,11 +42,13 @@ func (h *SignalingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) err if err != nil { return err } + h.l.Infof("createAndValidateParams %s", params.String()) donutEngine, err := h.donut.EngineFor(¶ms) if err != nil { return err } + h.l.Infof("EngineFor %#v", donutEngine) // server side media info serverStreamInfo, err := donutEngine.ServerIngredients() @@ -57,20 +60,30 @@ func (h *SignalingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) err if err != nil { return err } + h.l.Infof("ServerIngredients %#v", serverStreamInfo) + h.l.Infof("ClientIngredients %#v", clientStreamInfo) - donutRecipe := donutEngine.RecipeFor(serverStreamInfo, clientStreamInfo) - if donutRecipe == nil { - return entities.ErrMissingCompatibleStreams + donutRecipe, err := donutEngine.RecipeFor(serverStreamInfo, clientStreamInfo) + h.l.Info("after RecipeFor") + h.l.Info("after RecipeFor err", err) + h.l.Info("after RecipeFor donutRecipe", donutRecipe) + if err != nil { + return err } + h.l.Infof("RecipeFor %#v", donutRecipe) // We can't defer calling cancel here because it'll live alongside the stream. ctx, cancel := context.WithCancel(context.Background()) webRTCResponse, err := h.webRTCController.Setup(cancel, donutRecipe, params) + h.l.Infof("webRTCController.Setup %#v, err=%#v", webRTCResponse, err) if err != nil { cancel() return err } - + //tODO: add explan + h.l.Info("before sleeping") + time.Sleep(5 * time.Second) + h.l.Info("after sleeping") go donutEngine.Serve(&entities.DonutParameters{ Cancel: cancel, Ctx: ctx, @@ -85,13 +98,18 @@ func (h *SignalingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) err h.l.Errorw("error while streaming", "error", err) }, OnStream: func(st *entities.Stream) error { + h.l.Infof("onstream %#v", st) return h.webRTCController.SendMetadata(webRTCResponse.Data, st) }, OnVideoFrame: func(data []byte, c entities.MediaFrameContext) error { + // sl[len(sl)-1] + h.l.Infof("OnVideoFrame %#v < %d > First %#v Last %#v", c, len(data), data[0:7], data[len(data)-7:]) return h.webRTCController.SendMediaSample(webRTCResponse.Video, data, c) }, OnAudioFrame: func(data []byte, c entities.MediaFrameContext) error { - return h.webRTCController.SendMediaSample(webRTCResponse.Audio, data, c) + h.l.Infof("OnAudioFrame %#v", c) + return nil + // return h.webRTCController.SendMediaSample(webRTCResponse.Audio, data, c) }, }) @@ -99,6 +117,7 @@ func (h *SignalingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) err w.WriteHeader(http.StatusOK) err = json.NewEncoder(w).Encode(*webRTCResponse.LocalSDP) + h.l.Infof("webRTCResponse %#v", webRTCResponse) if err != nil { cancel() return err diff --git a/notes_bitstream_filter.txt b/notes_bitstream_filter.txt new file mode 100644 index 0000000..0f1c4b4 --- /dev/null +++ b/notes_bitstream_filter.txt @@ -0,0 +1,26 @@ +docker run -it --rm --entrypoint bash jrottenberg/ffmpeg:5.1.4-ubuntu2204 + +ffmpeg -bsfs | grep annex +# h264_mp4toannexb +# hevc_mp4toannexb + + +live_flv : In case of live network streams, if you force format, you may use live_flv option instead of flv to survive timestamp discontinuities. + +-f live_flv + +ff_live_flv_demuxer + +ff_flv_demuxer + +ffmpeg -h demuxer=live_flv +ffmpeg -h bsf=h264_mp4toannexb + +example: + +https://github.com/FFmpeg/FFmpeg/blob/9c6c4f3d476d7a8d423ec3b954254c6a67ebc792/libavformat/mux.c#L1351 +https://github.com/search?q=repo%3AFFmpeg%2FFFmpeg%20ff_stream_add_bitstream_filter&type=code + +Good explanaation +https://stackoverflow.com/questions/24884827/possible-locations-for-sequence-picture-parameter-sets-for-h-264-stream/24890903#24890903 +Filter impl https://github.com/FFmpeg/FFmpeg/blob/9c6c4f3d476d7a8d423ec3b954254c6a67ebc792/libavcodec/bsf/h264_mp4toannexb.c#L276 diff --git a/scripts/ffmpeg_mpegts_udp.sh b/scripts/ffmpeg_mpegts_udp.sh index e9c0d33..828b773 100755 --- a/scripts/ffmpeg_mpegts_udp.sh +++ b/scripts/ffmpeg_mpegts_udp.sh @@ -2,6 +2,7 @@ ffmpeg -hide_banner -loglevel verbose \ -re -f lavfi -i testsrc2=size=1280x720:rate=30,format=yuv420p \ -f lavfi -i sine=frequency=1000:sample_rate=44100 \ -c:v libx264 -preset veryfast -tune zerolatency -profile:v baseline \ + -vf "drawtext=text='SRT streaming':box=1:boxborderw=10:x=(w-text_w)/2:y=(h-text_h)/2:fontsize=128:fontcolor=black" \ -b:v 1000k -bufsize 2000k -x264opts keyint=30:min-keyint=30:scenecut=-1 \ -c:a aac -b:a 128k \ -f mpegts "udp://${SRT_INPUT_HOST}:${SRT_INPUT_PORT}?pkt_size=${PKT_SIZE}" \ No newline at end of file diff --git a/scripts/ffmpeg_rtmp.sh b/scripts/ffmpeg_rtmp.sh new file mode 100755 index 0000000..9320bf6 --- /dev/null +++ b/scripts/ffmpeg_rtmp.sh @@ -0,0 +1,13 @@ +#!/bin/bash +while true +do + ffmpeg -hide_banner -loglevel debug \ + -re -f lavfi -i testsrc2=size=1280x720:rate=30,format=yuv420p \ + -f lavfi -i sine=frequency=1000:sample_rate=44100 \ + -c:v libx264 -preset veryfast -tune zerolatency -profile:v baseline \ + -vf "drawtext=text='RTMP streaming':box=1:boxborderw=10:x=(w-text_w)/2:y=(h-text_h)/2:fontsize=128:fontcolor=black" \ + -bsf:v h264_mp4toannexb \ + -b:v 1000k -bufsize 2000k -x264opts keyint=30:min-keyint=30:scenecut=-1 \ + -c:a aac -b:a 128k \ + -f flv -listen 1 -rtmp_live live "rtmp://${RTMP_HOST}:${RTMP_PORT}/live/app" +done \ No newline at end of file diff --git a/static/demo.js b/static/demo.js index dbc489d..4291ff4 100644 --- a/static/demo.js +++ b/static/demo.js @@ -2,15 +2,13 @@ window.metadataMessages = {} window.startSession = () => { - let srtHost = document.getElementById('srt-host').value; - let srtPort = document.getElementById('srt-port').value; - let srtStreamId = document.getElementById('srt-stream-id').value; + let streamURL = document.getElementById('stream-url').value; + let streamID = document.getElementById('stream-id').value; setupWebRTC((pc, offer) => { let srtFullAddress = JSON.stringify({ - "srtHost": srtHost, - "srtPort": srtPort, - "srtStreamId": srtStreamId, + "streamURL": streamURL, + "streamID": streamID, offer }); @@ -142,7 +140,9 @@ const log = (msg, level = "info") => { const el = document.createElement("p") if (typeof(msg) !== "string") { + orig = msg msg = "unknown log msg type " + typeof(msg) + msg = msg + " [" + orig + "]" level = "error" } diff --git a/static/index.html b/static/index.html index c894846..44660dd 100644 --- a/static/index.html +++ b/static/index.html @@ -8,23 +8,21 @@
-