From 535d8feda1e8d87e92be13e1e747f3de8006f128 Mon Sep 17 00:00:00 2001 From: Sebastian Himberger Date: Thu, 10 Nov 2016 20:07:56 +0100 Subject: [PATCH] Improvements with concurrent encodings & stopping videos when leaving page --- NOTES.txt | 3 ++ README.md | 3 +- cache/.gitignore | 5 ++ http_command.go | 110 ++++++++++++++++++++++++++++++++++++++++ http_frame.go | 9 ++-- http_playlists.go | 4 +- http_stream.go | 20 +++++--- http_util.go | 28 ---------- main.go | 3 +- ui/assets/css/app.css | 2 +- ui/assets/gulpfile.js | 6 ++- ui/assets/js/app.js | 93 +++++++++++++++++---------------- ui/assets/jsx/app.jsx | 33 ++++++++---- ui/assets/package.json | 3 ++ ui/assets/sass/app.scss | 12 ++--- video_info.go | 3 +- 16 files changed, 227 insertions(+), 110 deletions(-) create mode 100644 NOTES.txt create mode 100644 cache/.gitignore create mode 100644 http_command.go diff --git a/NOTES.txt b/NOTES.txt new file mode 100644 index 0000000..7ae3142 --- /dev/null +++ b/NOTES.txt @@ -0,0 +1,3 @@ + + +https://bitbucket.org/walterebert/ffmpeg-hls/src/f14ccab90f1884ecee9bbccd025af87a3b2f837a/build.sh?at=default&fileviewer=file-view-default \ No newline at end of file diff --git a/README.md b/README.md index 8616591..4b0aea5 100644 --- a/README.md +++ b/README.md @@ -15,5 +15,4 @@ Running it License ------- -See LICENSE.txt - +See LICENSE.txt \ No newline at end of file diff --git a/cache/.gitignore b/cache/.gitignore new file mode 100644 index 0000000..b07ba2c --- /dev/null +++ b/cache/.gitignore @@ -0,0 +1,5 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore +!README.md \ No newline at end of file diff --git a/http_command.go b/http_command.go new file mode 100644 index 0000000..c59bff3 --- /dev/null +++ b/http_command.go @@ -0,0 +1,110 @@ +package main + +import ( + "sync" + //"net/http" + "bufio" + "crypto/sha1" + "fmt" + "io" + "log" + "os" + "os/exec" + "path/filepath" + "syscall" +) + +type Empty struct{} + +type HttpCommandHandler struct { + tokenChannel chan Empty + cacheDir string + inProgress map[string]string + inProgressMutex *sync.RWMutex + // path string +} + +func NewHttpCommandHandler(workerCount int, cacheDir string) *HttpCommandHandler { + ch := &HttpCommandHandler{make(chan Empty, workerCount), cacheDir, make(map[string]string), new(sync.RWMutex)} + for i := workerCount; i > 0; i-- { + ch.tokenChannel <- Empty{} + } + go ch.start() + return ch +} + +func (s *HttpCommandHandler) start() { + +} + +func (s *HttpCommandHandler) calculateKey(cmd string, args []string) string { + h := sha1.New() + h.Write([]byte(cmd)) + for _, v := range args { + h.Write([]byte(v)) + } + sum := h.Sum(nil) + return fmt.Sprintf("%x", sum) +} + +func (s *HttpCommandHandler) ServeCommand(cmdPath string, args []string, w io.Writer) error { + key := s.calculateKey(cmdPath, args) + token := <-s.tokenChannel + //log.Printf("Token: %v",key) + defer func() { + s.tokenChannel <- token + //log.Printf("Released token") + }() + cachePath := filepath.Join("cache", s.cacheDir, key) + mkerr := os.MkdirAll(filepath.Join("cache", s.cacheDir), 0777) + if mkerr != nil { + log.Printf("Could not create cache dir %v: %v", filepath.Join("cache", s.cacheDir), mkerr) + return mkerr + } + if file, err := os.Open(cachePath); err == nil { + defer file.Close() + _, err = io.Copy(w, file) + if err != nil { + log.Printf("Error copying file to client: %v", err) + return err + } + return nil + } + cacheFile, ferr := os.Create(cachePath) + if ferr != nil { + log.Printf("Could not create cache file %v: %v", cacheFile, ferr) + return ferr + } + defer cacheFile.Close() + log.Printf("Executing %v %v", cmdPath, args) + cmd := exec.Command(cmdPath, args...) + stdout, err := cmd.StdoutPipe() + defer stdout.Close() + if err != nil { + log.Printf("Error opening stdout of command: %v", err) + return err + } + err = cmd.Start() + if err != nil { + log.Printf("Error starting command: %v", err) + return err + } + filew := bufio.NewWriter(cacheFile) + multiw := io.MultiWriter(filew, w) + _, err = io.Copy(multiw, stdout) + if err != nil { + log.Printf("Error copying data to client: %v", err) + cacheFile.Close() + os.Remove(cachePath) + // Ask the process to exit + cmd.Process.Signal(syscall.SIGKILL) + cmd.Process.Wait() + return err + } + cmd.Wait() + filew.Flush() + log.Printf("Streaming done\n"); + return nil + //s.inProgressMutex.Lock() + //s.inProgressMutex.Unlock() +} diff --git a/http_frame.go b/http_frame.go index b86534c..7a13a7b 100644 --- a/http_frame.go +++ b/http_frame.go @@ -3,22 +3,21 @@ package main import ( "log" "net/http" - "os/exec" "path" ) type FrameHandler struct { - root string + root string + cmdHandler *HttpCommandHandler } func NewFrameHandler(root string) *FrameHandler { - return &FrameHandler{root} + return &FrameHandler{root, NewHttpCommandHandler(2, "frames")} } func (s *FrameHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := path.Join(s.root, r.URL.Path) - cmd := exec.Command("tools/ffmpeg", "-loglevel", "error", "-ss", "00:00:30", "-i", path, "-vf", "scale=320:-1", "-frames:v", "1", "-f", "image2", "-") - if err := ServeCommand(cmd, w); err != nil { + if err := s.cmdHandler.ServeCommand("tools/ffmpeg", []string{"-timelimit","10","-loglevel", "error", "-ss", "00:00:30", "-i", path, "-vf", "scale=320:-1", "-frames:v", "1", "-f", "image2", "-"}, w); err != nil { log.Printf("Error serving screenshot: %v", err) } } diff --git a/http_playlists.go b/http_playlists.go index 6a10eac..338c3f3 100644 --- a/http_playlists.go +++ b/http_playlists.go @@ -16,7 +16,7 @@ func UrlEncoded(str string) (string, error) { return u.String(), nil } -const hlsSegmentLength = 5.0 // 5 Seconds +const hlsSegmentLength = 5.0 // 10 Seconds type PlaylistHandler struct { root string @@ -49,7 +49,7 @@ func (s *PlaylistHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "#EXT-X-VERSION:3\n") fmt.Fprint(w, "#EXT-X-MEDIA-SEQUENCE:0\n") fmt.Fprint(w, "#EXT-X-ALLOW-CACHE:YES\n") - fmt.Fprint(w, "#EXT-X-TARGETDURATION:5\n") + fmt.Fprint(w, "#EXT-X-TARGETDURATION:"+fmt.Sprintf("%.f",hlsSegmentLength)+"\n") fmt.Fprint(w, "#EXT-X-PLAYLIST-TYPE:VOD\n") leftover := duration diff --git a/http_stream.go b/http_stream.go index 7a3fecd..5669ad7 100644 --- a/http_stream.go +++ b/http_stream.go @@ -2,19 +2,20 @@ package main import ( "fmt" + "log" "net/http" - "os/exec" "path" "strconv" "strings" ) type StreamHandler struct { - root string + root string + cmdHandler *HttpCommandHandler } func NewStreamHandler(root string) *StreamHandler { - return &StreamHandler{root} + return &StreamHandler{root, NewHttpCommandHandler(1, "segments")} } func (s *StreamHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -22,9 +23,14 @@ func (s *StreamHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { idx, _ := strconv.ParseInt(r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:strings.LastIndex(r.URL.Path, ".")], 0, 64) startTime := idx * hlsSegmentLength debug.Printf("Streaming second %v of %v", startTime, filePath) - w.Header()["Access-Control-Allow-Origin"] = []string{"*"} - - cmd := exec.Command("tools/ffmpeg", "-ss", fmt.Sprintf("%v", startTime), "-t", "5", "-i", filePath, "-vcodec", "libx264", "-strict", "experimental", "-acodec", "aac", "-pix_fmt", "yuv420p", "-r", "25", "-profile:v", "baseline", "-b:v", "2000k", "-maxrate", "2500k", "-f", "mpegts", "-") - ServeCommand(cmd, w) + // -ss = offset + // -t = duration + // -i = input + // -vcodec = video codec + // -acodec = audio codec + // --timelimit + if err := s.cmdHandler.ServeCommand("tools/ffmpeg", []string{"-timelimit","30","-ss", fmt.Sprintf("%v.00", startTime), "-t", fmt.Sprintf("%v.00", hlsSegmentLength), "-i", filePath, "-strict", "-2", "-vcodec", "libx264", "-acodec", "aac", "-pix_fmt", "yuv420p", "-r", "25", "-f", "mpegts", "-force_key_frames", "00:00:00.00", "-x264opts", "keyint=25:min-keyint=25:scenecut=-1", "-"}, w); err != nil { + log.Printf("Error streaming file %v and segment %v", filePath, idx) + } } diff --git a/http_util.go b/http_util.go index b6f19ed..0bde30d 100644 --- a/http_util.go +++ b/http_util.go @@ -2,37 +2,9 @@ package main import ( "encoding/json" - "io" - "log" "net/http" - "os/exec" - "syscall" ) -func ServeCommand(cmd *exec.Cmd, w io.Writer) error { - stdout, err := cmd.StdoutPipe() - defer stdout.Close() - if err != nil { - log.Printf("Error opening stdout of command: %v", err) - return err - } - err = cmd.Start() - if err != nil { - log.Printf("Error starting command: %v", err) - return err - } - _, err = io.Copy(w, stdout) - if err != nil { - log.Printf("Error copying data to client: %v", err) - // Ask the process to exit - cmd.Process.Signal(syscall.SIGKILL) - cmd.Process.Wait() - return err - } - cmd.Wait() - return nil -} - func ServeJson(status int, data interface{}, w http.ResponseWriter) { js, err := json.Marshal(data) if err != nil { diff --git a/main.go b/main.go index 8b9d994..af292c7 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,7 @@ func main() { contentDir = flag.Arg(0) } - http.Handle("/", http.RedirectHandler("/ui/",302)) + http.Handle("/", http.RedirectHandler("/ui/", 302)) http.Handle("/ui/assets/", http.StripPrefix("/ui/", http.FileServer(http.Dir(uiDirectory)))) http.Handle("/ui/", NewDebugHandlerWrapper(http.StripPrefix("/ui/", NewSingleFileServer(indexHtml)))) http.Handle("/list/", NewDebugHandlerWrapper(http.StripPrefix("/list/", NewListHandler(contentDir)))) @@ -23,5 +23,4 @@ func main() { http.Handle("/playlist/", NewDebugHandlerWrapper(http.StripPrefix("/playlist/", NewPlaylistHandler(contentDir)))) http.Handle("/segments/", NewDebugHandlerWrapper(http.StripPrefix("/segments/", NewStreamHandler(contentDir)))) http.ListenAndServe(":8080", nil) - } diff --git a/ui/assets/css/app.css b/ui/assets/css/app.css index 16d7699..6a76176 100644 --- a/ui/assets/css/app.css +++ b/ui/assets/css/app.css @@ -78,7 +78,7 @@ body, html { display: flex; } .player { - min-height: 100vh; + height: 100vh; background: black; display: flex; flex-direction: column; diff --git a/ui/assets/gulpfile.js b/ui/assets/gulpfile.js index 8077d18..e91c831 100644 --- a/ui/assets/gulpfile.js +++ b/ui/assets/gulpfile.js @@ -13,8 +13,10 @@ gulp.task('sass', function () { gulp.task('babel', function () { gulp.src('./jsx/*.jsx') - .pipe(plumber()) - .pipe(babel()) + .pipe(plumber()) + .pipe(babel({ + presets: ["react"] + })) .pipe(gulp.dest('./js/')); }); diff --git a/ui/assets/js/app.js b/ui/assets/js/app.js index f3fb4fc..c21763f 100644 --- a/ui/assets/js/app.js +++ b/ui/assets/js/app.js @@ -1,7 +1,5 @@ // Include RactRouter Module -"use strict"; - var Router = ReactRouter.create(); var Route = ReactRouter.Route; var RouteHandler = ReactRouter.RouteHandler; @@ -12,7 +10,7 @@ var Link = ReactRouter.Link; var App = React.createClass({ displayName: "App", - render: function render() { + render() { return React.createElement(RouteHandler, null); } }); @@ -20,6 +18,7 @@ var App = React.createClass({ var Player = React.createClass({ displayName: "Player", + // HLS.js doesn't seem to work somehow' /* componentDidMount() { @@ -28,23 +27,22 @@ var Player = React.createClass({ this.hls = new Hls({ debug: true, fragLoadingTimeOut: 60000, - - }); + }); let hls = this.hls; let props = this.props; hls.attachMedia(video); hls.on(Hls.Events.ERROR, function (event, data) { console.log(data); - }) + }) hls.on(Hls.Events.MEDIA_ATTACHED, function () { console.log("video and hls.js are now bound together !"); hls.loadSource("/playlist/" + props.params.splat); hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) { console.log(data) console.log("manifest loaded, found " + data.levels.length + " quality level"); - video.play(); + video.play(); }); - }); + }); } }, componentWillUnmount() { @@ -52,12 +50,25 @@ var Player = React.createClass({ }, */ - goBack: function goBack(e) { + componentWillUnmount() { + this.pauseVideo(); + // this.hls.detachMedia() + }, + + pauseVideo() { + let video = this._video.getDOMNode(); + video.pause(); + video.src = ""; + video.play(); + video.pause(); + }, + + goBack(e) { e.preventDefault(); window.history.back(); }, - render: function render() { + render() { return React.createElement( "div", { className: "player", key: this.props.path }, @@ -66,7 +77,7 @@ var Player = React.createClass({ { className: "stage" }, React.createElement("video", { src: "/playlist/" + this.props.params.splat, - + ref: c => this._video = c, width: "100%", controls: true, autoPlay: true }) ), React.createElement( @@ -81,7 +92,7 @@ var Player = React.createClass({ var Folder = React.createClass({ displayName: "Folder", - render: function render() { + render() { return React.createElement( Link, { to: "list", params: { "splat": this.props.path } }, @@ -114,7 +125,7 @@ var Folder = React.createClass({ var Loader = React.createClass({ displayName: "Loader", - render: function render() { + render() { return React.createElement( "div", { className: "loader" }, @@ -126,7 +137,7 @@ var Loader = React.createClass({ var EmptyMessage = React.createClass({ displayName: "EmptyMessage", - render: function render() { + render() { return React.createElement( "div", { className: "empty-message" }, @@ -142,7 +153,7 @@ var EmptyMessage = React.createClass({ var Video = React.createClass({ displayName: "Video", - render: function render() { + render() { return React.createElement( Link, { to: "play", params: { "splat": this.props.path } }, @@ -175,51 +186,46 @@ var Video = React.createClass({ var List = React.createClass({ displayName: "List", - getInitialState: function getInitialState() { + + getInitialState() { return { - "videos": null, - "folders": null + 'videos': null, + 'folders': null }; }, - fetchData: function fetchData(path) { - var _this = this; - + fetchData(path) { this.setState({ - "folders": null, - "videos": null + 'folders': null, + 'videos': null }); - $.get("/list/" + path, function (data) { - _this.setState({ - "folders": data.folders, - "videos": data.videos + $.get('/list/' + path, data => { + this.setState({ + 'folders': data.folders, + 'videos': data.videos }); }); }, - componentDidMount: function componentDidMount() { + componentDidMount() { var path = this.props.params.splat || ""; this.fetchData(path); }, - componentWillReceiveProps: function componentWillReceiveProps(nextProps) { + componentWillReceiveProps(nextProps) { var path = nextProps.params.splat || ""; this.fetchData(path); }, - render: function render() { - var loader = !this.state.folders ? React.createElement(Loader, null) : null; - var folders = []; - var videos = []; + render() { + let loader = !this.state.folders ? React.createElement(Loader, null) : null; + let folders = []; + let videos = []; if (this.state.folders) { - folders = this.state.folders.map(function (folder) { - return React.createElement(Folder, { key: folder.name, name: folder.name, path: folder.path }); - }); - videos = this.state.videos.map(function (video) { - return React.createElement(Video, { name: video.name, path: video.path, key: video.name }); - }); + folders = this.state.folders.map(folder => React.createElement(Folder, { key: folder.name, name: folder.name, path: folder.path })); + videos = this.state.videos.map(video => React.createElement(Video, { name: video.name, path: video.path, key: video.name })); } - var empty = this.state.folders != null && videos.length + folders.length == 0 ? React.createElement(EmptyMessage, null) : null; + let empty = this.state.folders != null && videos.length + folders.length == 0 ? React.createElement(EmptyMessage, null) : null; return React.createElement( "div", { className: "list" }, @@ -243,7 +249,6 @@ var routes = React.createElement( React.createElement(Route, { name: "play", path: "play/*", handler: Player }) ); -ReactRouter.run(routes, ReactRouter.HistoryLocation, function (Root) { - React.render(React.createElement(Root, null), document.getElementById("app")); -}); -// ref={(c) => this._video = c} \ No newline at end of file +ReactRouter.run(routes, ReactRouter.HistoryLocation, Root => { + React.render(React.createElement(Root, null), document.getElementById('app')); +}); \ No newline at end of file diff --git a/ui/assets/jsx/app.jsx b/ui/assets/jsx/app.jsx index e505726..3195cfe 100644 --- a/ui/assets/jsx/app.jsx +++ b/ui/assets/jsx/app.jsx @@ -25,23 +25,23 @@ var Player = React.createClass({ this.hls = new Hls({ debug: true, fragLoadingTimeOut: 60000, - + }); let hls = this.hls; let props = this.props; hls.attachMedia(video); hls.on(Hls.Events.ERROR, function (event, data) { console.log(data); - }) + }) hls.on(Hls.Events.MEDIA_ATTACHED, function () { console.log("video and hls.js are now bound together !"); hls.loadSource("/playlist/" + props.params.splat); hls.on(Hls.Events.MANIFEST_PARSED, function (event, data) { console.log(data) console.log("manifest loaded, found " + data.levels.length + " quality level"); - video.play(); + video.play(); }); - }); + }); } }, @@ -50,6 +50,19 @@ var Player = React.createClass({ }, */ + componentWillUnmount() { + this.pauseVideo(); + // this.hls.detachMedia() + }, + + pauseVideo() { + let video = this._video.getDOMNode(); + video.pause(); + video.src = ""; + video.play(); + video.pause(); + }, + goBack(e) { e.preventDefault(); window.history.back(); @@ -59,11 +72,11 @@ var Player = React.createClass({ return (
-