diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql
index 3332ad15b..f102e2171 100644
--- a/graphql/documents/data/config.graphql
+++ b/graphql/documents/data/config.graphql
@@ -51,6 +51,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
transcodeOutputArgs
liveTranscodeInputArgs
liveTranscodeOutputArgs
+ drawFunscriptHeatmapRange
}
fragment ConfigInterfaceData on ConfigInterfaceResult {
diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql
index 237fdacc6..359229a4a 100644
--- a/graphql/schema/types/config.graphql
+++ b/graphql/schema/types/config.graphql
@@ -84,6 +84,9 @@ input ConfigGeneralInput {
These are applied when live transcoding"""
liveTranscodeOutputArgs: [String!]
+ """whether to include range in generated funscript heatmaps"""
+ drawFunscriptHeatmapRange: Boolean
+
"""Write image thumbnails to disk when generating on the fly"""
writeImageThumbnails: Boolean
"""Username"""
@@ -184,6 +187,9 @@ type ConfigGeneralResult {
These are applied when live transcoding"""
liveTranscodeOutputArgs: [String!]!
+ """whether to include range in generated funscript heatmaps"""
+ drawFunscriptHeatmapRange: Boolean!
+
"""Write image thumbnails to disk when generating on the fly"""
writeImageThumbnails: Boolean!
"""API Key"""
diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go
index 8dc46277d..3b56d75bd 100644
--- a/internal/api/resolver_mutation_configure.go
+++ b/internal/api/resolver_mutation_configure.go
@@ -316,6 +316,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
c.Set(config.LiveTranscodeOutputArgs, input.LiveTranscodeOutputArgs)
}
+ if input.DrawFunscriptHeatmapRange != nil {
+ c.Set(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange)
+ }
+
if err := c.Write(); err != nil {
return makeConfigGeneralResult(), err
}
diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go
index aecc7d675..b9aae65de 100644
--- a/internal/api/resolver_query_configuration.go
+++ b/internal/api/resolver_query_configuration.go
@@ -128,6 +128,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
TranscodeOutputArgs: config.GetTranscodeOutputArgs(),
LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(),
LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(),
+ DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(),
}
}
diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go
index ea0ed4adb..7c954864d 100644
--- a/internal/manager/config/config.go
+++ b/internal/manager/config/config.go
@@ -185,6 +185,9 @@ const (
HandyKey = "handy_key"
FunscriptOffset = "funscript_offset"
+ DrawFunscriptHeatmapRange = "draw_funscript_heatmap_range"
+ drawFunscriptHeatmapRangeDefault = true
+
ThemeColor = "theme_color"
DefaultThemeColor = "#202b33"
@@ -831,6 +834,10 @@ func (i *Instance) GetLiveTranscodeOutputArgs() []string {
return i.getStringSlice(LiveTranscodeOutputArgs)
}
+func (i *Instance) GetDrawFunscriptHeatmapRange() bool {
+ return i.getBoolDefault(DrawFunscriptHeatmapRange, drawFunscriptHeatmapRangeDefault)
+}
+
// IsWriteImageThumbnails returns true if image thumbnails should be written
// to disk after generating on the fly.
func (i *Instance) IsWriteImageThumbnails() bool {
diff --git a/internal/manager/generator_interactive_heatmap_speed.go b/internal/manager/generator_interactive_heatmap_speed.go
index c9b295983..3b3b98bf4 100644
--- a/internal/manager/generator_interactive_heatmap_speed.go
+++ b/internal/manager/generator_interactive_heatmap_speed.go
@@ -15,14 +15,13 @@ import (
)
type InteractiveHeatmapSpeedGenerator struct {
- sceneDurationMilli int64
- InteractiveSpeed int
- Funscript Script
- FunscriptPath string
- HeatmapPath string
- Width int
- Height int
- NumSegments int
+ InteractiveSpeed int
+ Funscript Script
+ Width int
+ Height int
+ NumSegments int
+
+ DrawRange bool
}
type Script struct {
@@ -33,8 +32,7 @@ type Script struct {
// Range is the percentage of a full stroke to use.
Range int `json:"range,omitempty"`
// Actions are the timed moves.
- Actions []Action `json:"actions"`
- AvarageSpeed int64
+ Actions []Action `json:"actions"`
}
// Action is a move at a specific time.
@@ -50,23 +48,22 @@ type Action struct {
}
type GradientTable []struct {
- Col colorful.Color
- Pos float64
+ Col colorful.Color
+ Pos float64
+ YRange [2]float64
}
-func NewInteractiveHeatmapSpeedGenerator(funscriptPath string, heatmapPath string, sceneDuration float64) *InteractiveHeatmapSpeedGenerator {
+func NewInteractiveHeatmapSpeedGenerator(drawRange bool) *InteractiveHeatmapSpeedGenerator {
return &InteractiveHeatmapSpeedGenerator{
- sceneDurationMilli: int64(sceneDuration * 1000),
- FunscriptPath: funscriptPath,
- HeatmapPath: heatmapPath,
- Width: 320,
- Height: 15,
- NumSegments: 150,
+ Width: 1280,
+ Height: 60,
+ NumSegments: 600,
+ DrawRange: drawRange,
}
}
-func (g *InteractiveHeatmapSpeedGenerator) Generate() error {
- funscript, err := g.LoadFunscriptData(g.FunscriptPath)
+func (g *InteractiveHeatmapSpeedGenerator) Generate(funscriptPath string, heatmapPath string, sceneDuration float64) error {
+ funscript, err := g.LoadFunscriptData(funscriptPath, sceneDuration)
if err != nil {
return err
@@ -79,7 +76,7 @@ func (g *InteractiveHeatmapSpeedGenerator) Generate() error {
g.Funscript = funscript
g.Funscript.UpdateIntensityAndSpeed()
- err = g.RenderHeatmap()
+ err = g.RenderHeatmap(heatmapPath)
if err != nil {
return err
@@ -90,7 +87,7 @@ func (g *InteractiveHeatmapSpeedGenerator) Generate() error {
return nil
}
-func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string) (Script, error) {
+func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneDuration float64) (Script, error) {
data, err := os.ReadFile(path)
if err != nil {
return Script{}, err
@@ -111,8 +108,9 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string) (Scrip
// trim actions with negative timestamps to avoid index range errors when generating heatmap
// #3181 - also trim actions that occur after the scene duration
loggedBadTimestamp := false
+ sceneDurationMilli := int64(sceneDuration * 1000)
isValid := func(x int64) bool {
- return x >= 0 && x < g.sceneDurationMilli
+ return x >= 0 && x < sceneDurationMilli
}
i := 0
@@ -157,14 +155,27 @@ func (funscript *Script) UpdateIntensityAndSpeed() {
}
// funscript needs to have intensity updated first
-func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap() error {
-
+func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap(heatmapPath string) error {
gradient := g.Funscript.getGradientTable(g.NumSegments)
img := image.NewRGBA(image.Rect(0, 0, g.Width, g.Height))
for x := 0; x < g.Width; x++ {
- c := gradient.GetInterpolatedColorFor(float64(x) / float64(g.Width))
- draw.Draw(img, image.Rect(x, 0, x+1, g.Height), &image.Uniform{c}, image.Point{}, draw.Src)
+ xPos := float64(x) / float64(g.Width)
+ c := gradient.GetInterpolatedColorFor(xPos)
+
+ y0 := 0
+ y1 := g.Height
+
+ if g.DrawRange {
+ yRange := gradient.GetYRange(xPos)
+ top := int(yRange[0] / 100.0 * float64(g.Height))
+ bottom := int(yRange[1] / 100.0 * float64(g.Height))
+
+ y0 = g.Height - top
+ y1 = g.Height - bottom
+ }
+
+ draw.Draw(img, image.Rect(x, y0, x+1, y1), &image.Uniform{c}, image.Point{}, draw.Src)
}
// add 10 minute marks
@@ -178,7 +189,7 @@ func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap() error {
ts += tick
}
- outpng, err := os.Create(g.HeatmapPath)
+ outpng, err := os.Create(heatmapPath)
if err != nil {
return err
}
@@ -217,27 +228,96 @@ func (gt GradientTable) GetInterpolatedColorFor(t float64) colorful.Color {
return gt[len(gt)-1].Col
}
+func (gt GradientTable) GetYRange(t float64) [2]float64 {
+ for i := 0; i < len(gt)-1; i++ {
+ c1 := gt[i]
+ c2 := gt[i+1]
+ if c1.Pos <= t && t <= c2.Pos {
+ // TODO: We are in between c1 and c2. Go blend them!
+ return c1.YRange
+ }
+ }
+
+ // Nothing found? Means we're at (or past) the last gradient keypoint.
+ return gt[len(gt)-1].YRange
+}
+
func (funscript Script) getGradientTable(numSegments int) GradientTable {
+ const windowSize = 15
+ const backfillThreshold = 500
+
segments := make([]struct {
count int
intensity int
+ yRange [2]float64
+ at int64
}, numSegments)
gradient := make(GradientTable, numSegments)
+ posList := []int{}
maxts := funscript.Actions[len(funscript.Actions)-1].At
for _, a := range funscript.Actions {
+ posList = append(posList, a.Pos)
+
+ if len(posList) > windowSize {
+ posList = posList[1:]
+ }
+
+ sortedPos := make([]int, len(posList))
+ copy(sortedPos, posList)
+ sort.Ints(sortedPos)
+
+ topHalf := sortedPos[len(sortedPos)/2:]
+ bottomHalf := sortedPos[0 : len(sortedPos)/2]
+
+ var totalBottom int
+ var totalTop int
+
+ for _, value := range bottomHalf {
+ totalBottom += value
+ }
+ for _, value := range topHalf {
+ totalTop += value
+ }
+
+ averageBottom := float64(totalBottom) / float64(len(bottomHalf))
+ averageTop := float64(totalTop) / float64(len(topHalf))
+
segment := int(float64(a.At) / float64(maxts+1) * float64(numSegments))
// #3181 - sanity check. Clamp segment to numSegments-1
if segment >= numSegments {
segment = numSegments - 1
}
+ segments[segment].at = a.At
segments[segment].count++
segments[segment].intensity += int(a.Intensity)
+ segments[segment].yRange[0] = averageTop
+ segments[segment].yRange[1] = averageBottom
+ }
+
+ lastSegment := segments[0]
+
+ // Fill in gaps in segments
+ for i := 0; i < numSegments; i++ {
+ segmentTS := int64(float64(i) / float64(numSegments))
+
+ // Empty segment - fill it with the previous up to backfillThreshold ms
+ if segments[i].count == 0 {
+ if segmentTS-lastSegment.at < backfillThreshold {
+ segments[i].count = lastSegment.count
+ segments[i].intensity = lastSegment.intensity
+ segments[i].yRange[0] = lastSegment.yRange[0]
+ segments[i].yRange[1] = lastSegment.yRange[1]
+ }
+ } else {
+ lastSegment = segments[i]
+ }
}
for i := 0; i < numSegments; i++ {
gradient[i].Pos = float64(i) / float64(numSegments-1)
+ gradient[i].YRange = segments[i].yRange
if segments[i].count > 0 {
gradient[i].Col = getSegmentColor(float64(segments[i].intensity) / float64(segments[i].count))
} else {
diff --git a/internal/manager/task_generate_interactive_heatmap_speed.go b/internal/manager/task_generate_interactive_heatmap_speed.go
index 414584a77..564004b8e 100644
--- a/internal/manager/task_generate_interactive_heatmap_speed.go
+++ b/internal/manager/task_generate_interactive_heatmap_speed.go
@@ -29,10 +29,11 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) {
videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm)
funscriptPath := video.GetFunscriptPath(t.Scene.Path)
heatmapPath := instance.Paths.Scene.GetInteractiveHeatmapPath(videoChecksum)
+ drawRange := instance.Config.GetDrawFunscriptHeatmapRange()
- generator := NewInteractiveHeatmapSpeedGenerator(funscriptPath, heatmapPath, t.Scene.Files.Primary().Duration)
+ generator := NewInteractiveHeatmapSpeedGenerator(drawRange)
- err := generator.Generate()
+ err := generator.Generate(funscriptPath, heatmapPath, t.Scene.Files.Primary().Duration)
if err != nil {
logger.Errorf("error generating heatmap: %s", err.Error())
diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx
index 569714248..108f9652b 100644
--- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx
+++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx
@@ -315,6 +315,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
/>
+
+ saveGeneral({ drawFunscriptHeatmapRange: v })}
+ />
+
+