diff --git a/go.mod b/go.mod index 81c2442b6..09501a98c 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/bregydoc/gtranslate v0.0.0-20200913051839-1bd07f6c1fc5 github.com/creasty/defaults v1.5.1 github.com/darwayne/go-timecode v1.1.0 + github.com/disintegration/imaging v1.6.0 github.com/djherbis/times v1.2.0 github.com/dsnet/compress v0.0.1 // indirect github.com/dustin/go-humanize v1.0.0 diff --git a/pkg/api/deovr.go b/pkg/api/deovr.go index 471a8cf62..f7510ce98 100644 --- a/pkg/api/deovr.go +++ b/pkg/api/deovr.go @@ -337,10 +337,14 @@ func (i DeoVRResource) getDeoScene(req *restful.Request, resp *restful.Response) screenType = "sphere" } - var title = scene.Title + title := scene.Title + thumbnailURL := session.DeoRequestHost + "/img/700x/" + strings.Replace(scene.CoverURL, "://", ":/", -1) if scene.IsScripted { title = scene.GetFunscriptTitle() + if config.Config.Interfaces.DeoVR.RenderHeatmaps { + thumbnailURL = session.DeoRequestHost + "/imghm/" + fmt.Sprint(scene.ID) + "/" + strings.Replace(scene.CoverURL, "://", ":/", -1) + } } deoScene := DeoScene{ @@ -354,7 +358,7 @@ func (i DeoVRResource) getDeoScene(req *restful.Request, resp *restful.Response) RatingAvg: scene.StarRating, FullVideoReady: true, FullAccess: true, - ThumbnailURL: session.DeoRequestHost + "/img/700x/" + strings.Replace(scene.CoverURL, "://", ":/", -1), + ThumbnailURL: thumbnailURL, StereoMode: stereoMode, Is3D: true, ScreenType: screenType, @@ -426,10 +430,16 @@ func scenesToDeoList(req *restful.Request, scenes []models.Scene) []DeoListItem list := make([]DeoListItem, 0) for i := range scenes { + thumbnailURL := fmt.Sprintf("%v/img/700x/%v", session.DeoRequestHost, strings.Replace(scenes[i].CoverURL, "://", ":/", -1)) + + if config.Config.Interfaces.DeoVR.RenderHeatmaps && scenes[i].IsScripted { + thumbnailURL = fmt.Sprintf("%v/imghm/%d/%v", session.DeoRequestHost, scenes[i].ID, strings.Replace(scenes[i].CoverURL, "://", ":/", -1)) + } + item := DeoListItem{ Title: scenes[i].Title, VideoLength: scenes[i].Duration * 60, - ThumbnailURL: fmt.Sprintf("%v/img/700x/%v", session.DeoRequestHost, strings.Replace(scenes[i].CoverURL, "://", ":/", -1)), + ThumbnailURL: thumbnailURL, VideoURL: fmt.Sprintf("%v/deovr/%v", session.DeoRequestHost, scenes[i].ID), } list = append(list, item) diff --git a/pkg/api/options.go b/pkg/api/options.go index 268603780..c0b408fe6 100644 --- a/pkg/api/options.go +++ b/pkg/api/options.go @@ -50,11 +50,12 @@ type RequestSaveOptionsDLNA struct { } type RequestSaveOptionsDeoVR struct { - Enabled bool `json:"enabled"` - AuthEnabled bool `json:"auth_enabled"` - Username string `json:"username"` - Password string `json:"password"` - RemoteEnabled bool `json:"remote_enabled"` + Enabled bool `json:"enabled"` + AuthEnabled bool `json:"auth_enabled"` + Username string `json:"username"` + Password string `json:"password"` + RemoteEnabled bool `json:"remote_enabled"` + RenderHeatmaps bool `json:"render_heatmaps"` } type RequestSaveOptionsPreviews struct { @@ -229,6 +230,7 @@ func (i ConfigResource) saveOptionsDeoVR(req *restful.Request, resp *restful.Res config.Config.Interfaces.DeoVR.Enabled = r.Enabled config.Config.Interfaces.DeoVR.AuthEnabled = r.AuthEnabled + config.Config.Interfaces.DeoVR.RenderHeatmaps = r.RenderHeatmaps config.Config.Interfaces.DeoVR.RemoteEnabled = r.RemoteEnabled config.Config.Interfaces.DeoVR.Username = r.Username if r.Password != config.Config.Interfaces.DeoVR.Password && r.Password != "" { diff --git a/pkg/config/config.go b/pkg/config/config.go index a0c264297..2487253b0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -29,11 +29,12 @@ type ObjectConfig struct { AllowedIP []string `default:"[]" json:"allowedIp"` } `json:"dlna"` DeoVR struct { - Enabled bool `default:"true" json:"enabled"` - AuthEnabled bool `default:"false" json:"auth_enabled"` - RemoteEnabled bool `default:"false" json:"remote_enabled"` - Username string `default:"" json:"username"` - Password string `default:"" json:"password"` + Enabled bool `default:"true" json:"enabled"` + AuthEnabled bool `default:"false" json:"auth_enabled"` + RenderHeatmaps bool `default:"false" json:"render_heatmaps"` + RemoteEnabled bool `default:"false" json:"remote_enabled"` + Username string `default:"" json:"username"` + Password string `default:"" json:"password"` } `json:"deovr"` } `json:"interfaces"` Library struct { diff --git a/pkg/server/heatmapproxy.go b/pkg/server/heatmapproxy.go new file mode 100644 index 000000000..ff012d7a5 --- /dev/null +++ b/pkg/server/heatmapproxy.go @@ -0,0 +1,194 @@ +package server + +import ( + "bytes" + "fmt" + "image" + "image/draw" + "image/jpeg" + "image/png" + "io" + "io/ioutil" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/disintegration/imaging" + "github.com/xbapps/xbvr/pkg/common" + "github.com/xbapps/xbvr/pkg/models" + "willnorris.com/go/imageproxy" +) + +const thumbnailWidth = 700 +const thumbnailHeight = 420 +const heatmapHeight = 10 +const heatmapMargin = 3 + +type BufferResponseWriter struct { + header http.Header + statusCode int + buf *bytes.Buffer +} + +func (myrw *BufferResponseWriter) Write(p []byte) (int, error) { + return myrw.buf.Write(p) +} + +func (w *BufferResponseWriter) Header() http.Header { + return w.header +} + +func (w *BufferResponseWriter) WriteHeader(statusCode int) { + w.statusCode = statusCode +} + +type HeatmapThumbnailProxy struct { + ImageProxy *imageproxy.Proxy + Cache imageproxy.Cache +} + +func NewHeatmapThumbnailProxy(imageproxy *imageproxy.Proxy, cache imageproxy.Cache) *HeatmapThumbnailProxy { + proxy := &HeatmapThumbnailProxy{ + ImageProxy: imageproxy, + Cache: cache, + } + return proxy +} + +func getScriptFileId(urlpart string) (uint, error) { + sceneId, err := strconv.Atoi(urlpart) + if err != nil { + return 0, err + } + + var scene models.Scene + err = scene.GetIfExistByPK(uint(sceneId)) + if err != nil { + return 0, err + } + scriptfiles, err := scene.GetScriptFiles() + if err != nil || len(scriptfiles) < 1 { + return 0, fmt.Errorf("scene %d has no script files", sceneId) + } + return scriptfiles[0].ID, nil +} + +func getHeatmapImageForScene(fileId uint) (image.Image, error) { + + heatmapFilename := filepath.Join(common.ScriptHeatmapDir, fmt.Sprintf("heatmap-%d.png", fileId)) + heatmapFile, err := os.Open(heatmapFilename) + if err != nil { + return nil, err + } + + heatmapImage, err := png.Decode(heatmapFile) + heatmapFile.Close() + if err != nil { + return nil, err + } + + return heatmapImage, nil +} + +func createHeatmapThumbnail(out *bytes.Buffer, r io.Reader, heatmapImage image.Image) error { + thumbnailImage, err := jpeg.Decode(r) + + if err != nil { + return err + } + + rect := thumbnailImage.Bounds() + if rect.Dx() != thumbnailWidth || rect.Dy() != thumbnailHeight-heatmapHeight-heatmapMargin { + thumbnailImage = imaging.Fill(thumbnailImage, thumbnailWidth, thumbnailHeight-heatmapHeight-heatmapMargin, imaging.Center, imaging.Linear) + } + heatmapImage = imaging.Resize(heatmapImage, thumbnailWidth, heatmapHeight, imaging.Linear) + + canvas := image.NewNRGBA(image.Rect(0, 0, thumbnailWidth, thumbnailHeight)) + + drawRect := image.Rect(0, 0, thumbnailWidth, thumbnailHeight-heatmapHeight-heatmapMargin) + draw.Draw(canvas, drawRect, thumbnailImage, image.Point{}, draw.Over) + drawRect = image.Rect(0, thumbnailHeight-heatmapHeight, thumbnailWidth, thumbnailHeight) + draw.Draw(canvas, drawRect, heatmapImage, image.Point{}, draw.Over) + jpeg.Encode(out, canvas, &jpeg.Options{Quality: 90}) + return nil +} + +func (p *HeatmapThumbnailProxy) serveImageproxyResponse(w http.ResponseWriter, r *http.Request, imageURL string) { + proxyURL := "/700x/" + imageURL + r2 := new(http.Request) + *r2 = *r + r2.URL = new(url.URL) + *r2.URL = *r.URL + r2.URL.Path = proxyURL + p.ImageProxy.ServeHTTP(w, r2) +} + +func (p *HeatmapThumbnailProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { + + parts := strings.SplitN(r.URL.Path, "/", 3) + if len(parts) != 3 { + http.NotFound(w, r) + return + } + + imageURL := parts[2] + fileId, err := getScriptFileId(parts[1]) + if err != nil { + p.serveImageproxyResponse(w, r, imageURL) + return + } + + cacheKey := fmt.Sprintf("%d:%s", fileId, imageURL) + cachedContent, ok := p.Cache.Get(cacheKey) + if ok { + w.Header().Add("Content-Type", "image/jpeg") + w.Header().Add("Content-Length", fmt.Sprint(len(cachedContent))) + if _, err := io.Copy(w, bytes.NewReader(cachedContent)); err != nil { + log.Printf("Failed to send out response: %v", err) + } + return + } + + heatmapImage, err := getHeatmapImageForScene(fileId) + if err != nil { + p.serveImageproxyResponse(w, r, imageURL) + return + } + + proxyURL := fmt.Sprintf("/%dx%d,jpeg/%s", thumbnailWidth, thumbnailHeight-heatmapHeight-heatmapMargin, imageURL) + r2 := new(http.Request) + *r2 = *r + r2.URL = new(url.URL) + *r2.URL = *r.URL + r2.URL.Path = proxyURL + imageproxyResponseWriter := &BufferResponseWriter{ + header: http.Header{}, + buf: &bytes.Buffer{}, + } + p.ImageProxy.ServeHTTP(imageproxyResponseWriter, r2) + + respbody, err := ioutil.ReadAll(imageproxyResponseWriter.buf) + if err == nil { + var output bytes.Buffer + err = createHeatmapThumbnail(&output, bytes.NewReader(respbody), heatmapImage) + if err == nil { + p.Cache.Set(cacheKey, output.Bytes()) + w.Header().Add("Content-Type", "image/jpeg") + w.Header().Add("Content-Length", fmt.Sprint(len(output.Bytes()))) + if _, err := io.Copy(w, bytes.NewReader(output.Bytes())); err != nil { + log.Printf("Failed to send out response: %v", err) + } + return + } + } + if err != nil { + log.Printf("%v", err) + // serve original response + if _, err := io.Copy(w, bytes.NewReader(respbody)); err != nil { + log.Printf("Failed to send out response: %v", err) + } + } +} diff --git a/pkg/server/server.go b/pkg/server/server.go index ada35ed11..d09a8c031 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -131,6 +131,8 @@ func StartServer(version, commit, branch, date string) { p := imageproxy.NewProxy(nil, diskCache(filepath.Join(common.AppDir, "imageproxy"))) p.UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.103 Safari/537.36" r.PathPrefix("/img/").Handler(http.StripPrefix("/img", p)) + hmp := NewHeatmapThumbnailProxy(p, diskCache(filepath.Join(common.AppDir, "heatmapthumbnailproxy"))) + r.PathPrefix("/imghm/").Handler(http.StripPrefix("/imghm", hmp)) r.SkipClean(true) r.PathPrefix("/").Handler(http.DefaultServeMux) diff --git a/pkg/tasks/heatmap.go b/pkg/tasks/heatmap.go index 9847db235..85cf3f5bc 100644 --- a/pkg/tasks/heatmap.go +++ b/pkg/tasks/heatmap.go @@ -13,6 +13,7 @@ import ( "sort" "github.com/lucasb-eyer/go-colorful" + "github.com/sirupsen/logrus" "github.com/xbapps/xbvr/pkg/common" "github.com/xbapps/xbvr/pkg/models" ) @@ -45,7 +46,7 @@ type GradientTable []struct { Pos float64 } -func GenerateHeatmaps() { +func GenerateHeatmaps(tlog *logrus.Entry) { if !models.CheckLock("heatmaps") { models.CreateLock("heatmaps") @@ -55,7 +56,10 @@ func GenerateHeatmaps() { var scriptfiles []models.File db.Model(&models.File{}).Preload("Volume").Where("type = ?", "script").Where("has_heatmap = ?", false).Find(&scriptfiles) - for _, file := range scriptfiles { + for i, file := range scriptfiles { + if tlog != nil && (i%50) == 0 { + tlog.Infof("Generating heatmaps (%v/%v)", i+1, len(scriptfiles)) + } if file.Exists() { log.Infof("Rendering %v", file.Filename) destFile := filepath.Join(common.ScriptHeatmapDir, fmt.Sprintf("heatmap-%d.png", file.ID)) @@ -79,17 +83,32 @@ func GenerateHeatmaps() { models.RemoveLock("heatmaps") } -func RenderHeatmap(inputFile string, destFile string, width, height, numSegments int) error { - data, err := ioutil.ReadFile(inputFile) +func LoadFunscriptData(path string) (Script, error) { + data, err := ioutil.ReadFile(path) if err != nil { - return err + return Script{}, err } var funscript Script - json.Unmarshal(data, &funscript) + err = json.Unmarshal(data, &funscript) + if err != nil { + return Script{}, err + } + + if funscript.Actions == nil { + return Script{}, fmt.Errorf("actions list missing in %s", path) + } + sort.SliceStable(funscript.Actions, func(i, j int) bool { return funscript.Actions[i].At < funscript.Actions[j].At }) - funscript.UpdateIntesity() + return funscript, nil +} + +func RenderHeatmap(inputFile string, destFile string, width, height, numSegments int) error { + + funscript, err := LoadFunscriptData(inputFile) + + funscript.UpdateIntensity() gradient := funscript.getGradientTable(numSegments) img := image.NewRGBA(image.Rect(0, 0, width, height)) @@ -98,6 +117,17 @@ func RenderHeatmap(inputFile string, destFile string, width, height, numSegments draw.Draw(img, image.Rect(x, 0, x+1, height), &image.Uniform{c}, image.Point{}, draw.Src) } + // add 10 minute marks + maxts := funscript.Actions[len(funscript.Actions)-1].At + const tick = 600000 + var ts int64 = tick + c, _ := colorful.Hex("#000000") + for ts < maxts { + x := int(float64(ts) / float64(maxts) * float64(width)) + draw.Draw(img, image.Rect(x-1, height/2, x+1, height), &image.Uniform{c}, image.Point{}, draw.Src) + ts += tick + } + outpng, err := os.Create(destFile) if err != nil { return fmt.Errorf("Error storing png: " + err.Error()) @@ -123,7 +153,7 @@ func (gt GradientTable) GetInterpolatedColorFor(t float64) colorful.Color { return gt[len(gt)-1].Col } -func (funscript Script) UpdateIntesity() { +func (funscript Script) UpdateIntensity() { var t1, t2 int64 var p1, p2 int @@ -186,12 +216,7 @@ func (funscript Script) getGradientTable(numSegments int) GradientTable { }, numSegments) gradient := make(GradientTable, numSegments) - var maxts int64 = 0 - for _, a := range funscript.Actions { - if a.At > maxts { - maxts = a.At - } - } + maxts := funscript.Actions[len(funscript.Actions)-1].At for _, a := range funscript.Actions { segment := int(float64(a.At) / float64(maxts+1) * float64(numSegments)) @@ -210,3 +235,14 @@ func (funscript Script) getGradientTable(numSegments int) GradientTable { return gradient } + +func getFunscriptDuration(path string) (float64, error) { + funscript, err := LoadFunscriptData(path) + if err != nil { + return 0.0, err + } + + maxts := funscript.Actions[len(funscript.Actions)-1].At + + return float64(maxts) / 1000.0, nil +} diff --git a/pkg/tasks/volume.go b/pkg/tasks/volume.go index 794bacf72..1b37ac9a6 100644 --- a/pkg/tasks/volume.go +++ b/pkg/tasks/volume.go @@ -88,7 +88,7 @@ func RescanVolumes() { tlog.Infof("Generating heatmaps") - GenerateHeatmaps() + GenerateHeatmaps(tlog) tlog.Infof("Scanning complete") diff --git a/ui/src/store/optionsDeoVR.js b/ui/src/store/optionsDeoVR.js index 5e540cbe9..8c17f4e0d 100644 --- a/ui/src/store/optionsDeoVR.js +++ b/ui/src/store/optionsDeoVR.js @@ -5,6 +5,7 @@ const state = { deovr: { enabled: false, auth_enabled: false, + render_heatmaps: false, remote_enabled: false, username: '', password: '', @@ -22,6 +23,7 @@ const actions = { .then(data => { state.deovr.enabled = data.config.interfaces.deovr.enabled state.deovr.auth_enabled = data.config.interfaces.deovr.auth_enabled + state.deovr.render_heatmaps = data.config.interfaces.deovr.render_heatmaps state.deovr.remote_enabled = data.config.interfaces.deovr.remote_enabled state.deovr.username = data.config.interfaces.deovr.username state.deovr.password = data.config.interfaces.deovr.password diff --git a/ui/src/store/optionsFunscripts.js b/ui/src/store/optionsFunscripts.js index 5c9acca0c..41f5cece1 100644 --- a/ui/src/store/optionsFunscripts.js +++ b/ui/src/store/optionsFunscripts.js @@ -8,7 +8,7 @@ const state = { const mutations = {} const actions = { - async load ({ state }, params) { + async load({ state }, params) { ky.get('/api/options/funscripts/count') .json() .then(data => { diff --git a/ui/src/views/options/sections/Funscripts.vue b/ui/src/views/options/sections/Funscripts.vue index bda5d7d15..6e71c1cae 100644 --- a/ui/src/views/options/sections/Funscripts.vue +++ b/ui/src/views/options/sections/Funscripts.vue @@ -1,59 +1,80 @@ diff --git a/ui/src/views/options/sections/InterfaceDeoVR.vue b/ui/src/views/options/sections/InterfaceDeoVR.vue index bc7855f6d..58ca11181 100644 --- a/ui/src/views/options/sections/InterfaceDeoVR.vue +++ b/ui/src/views/options/sections/InterfaceDeoVR.vue @@ -30,6 +30,17 @@
+
+ + + Enabled + + +

+ If you are using funscripts, you can add a heatmap to the thumbnails of scripted scenes in the DeoVR interface. +

+
+
@@ -106,6 +117,14 @@ export default { this.$store.state.optionsDeoVR.deovr.auth_enabled = value } }, + renderHeatmaps: { + get () { + return this.$store.state.optionsDeoVR.deovr.render_heatmaps + }, + set (value) { + this.$store.state.optionsDeoVR.deovr.render_heatmaps = value + } + }, remoteEnabled: { get () { return this.$store.state.optionsDeoVR.deovr.remote_enabled