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 @@
-
-
{{$t('Export funscripts')}}
+
{{ $t("Export funscripts") }}
{{$t('Here you can download a ZIP file containing a funscript for each scripted scene. The file names include scene title and scene id, as expected by DeoVR. If a scene has multiple scripts you can choose a preferred script in the scene details view. Otherwise, the most recently added script is chosen.')}}
- {{$t('Note that the filenames are not compatible with DLNA.')}}
+ {{ $t("Note that the filenames are not compatible with DLNA.") }}
- {{$t('To use this export with DeoVR: Unzip and put the files in the Interactive folder on your device.')}}
+ {{
+ $t(
+ "To use this export with DeoVR: Unzip and put the files in the Interactive folder on your device."
+ )
+ }}
- {{$t('To use this export with ScriptPlayer: Unzip and put the files in a folder of your choice. In the ScriptPlayer settings, add this folder in the Paths section, then connect to DeoVR.')}}
+ {{
+ $t(
+ "To use this export with ScriptPlayer: Unzip and put the files in a folder of your choice. In the ScriptPlayer settings, add this folder in the Paths section, then connect to DeoVR."
+ )
+ }}
-
+
Download funscripts for DeoVR
- {{$t('Download all funscripts')}} ({{countTotal}})
+ {{ $t("Download all funscripts") }} ({{ countTotal }})
- {{$t('Download changes since last export')}} ({{countUpdated}})
+ {{ $t("Download changes since last export") }} ({{
+ countUpdated
+ }})
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