Add ranges to funscript heatmaps (#3373)

* Add range to funscript heatmaps
* Add config to draw heatmap range
* Add setting to UI
* Apply draw range setting

Includes some refactoring

---------

Co-authored-by: kermieisinthehouse <kermie@isinthe.house>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
pornstasche
2023-02-23 02:33:22 +01:00
committed by GitHub
parent 2b84392df7
commit 75a8d572cc
10 changed files with 145 additions and 31 deletions

View File

@@ -51,6 +51,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
transcodeOutputArgs transcodeOutputArgs
liveTranscodeInputArgs liveTranscodeInputArgs
liveTranscodeOutputArgs liveTranscodeOutputArgs
drawFunscriptHeatmapRange
} }
fragment ConfigInterfaceData on ConfigInterfaceResult { fragment ConfigInterfaceData on ConfigInterfaceResult {

View File

@@ -84,6 +84,9 @@ input ConfigGeneralInput {
These are applied when live transcoding""" These are applied when live transcoding"""
liveTranscodeOutputArgs: [String!] liveTranscodeOutputArgs: [String!]
"""whether to include range in generated funscript heatmaps"""
drawFunscriptHeatmapRange: Boolean
"""Write image thumbnails to disk when generating on the fly""" """Write image thumbnails to disk when generating on the fly"""
writeImageThumbnails: Boolean writeImageThumbnails: Boolean
"""Username""" """Username"""
@@ -184,6 +187,9 @@ type ConfigGeneralResult {
These are applied when live transcoding""" These are applied when live transcoding"""
liveTranscodeOutputArgs: [String!]! liveTranscodeOutputArgs: [String!]!
"""whether to include range in generated funscript heatmaps"""
drawFunscriptHeatmapRange: Boolean!
"""Write image thumbnails to disk when generating on the fly""" """Write image thumbnails to disk when generating on the fly"""
writeImageThumbnails: Boolean! writeImageThumbnails: Boolean!
"""API Key""" """API Key"""

View File

@@ -316,6 +316,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
c.Set(config.LiveTranscodeOutputArgs, input.LiveTranscodeOutputArgs) c.Set(config.LiveTranscodeOutputArgs, input.LiveTranscodeOutputArgs)
} }
if input.DrawFunscriptHeatmapRange != nil {
c.Set(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange)
}
if err := c.Write(); err != nil { if err := c.Write(); err != nil {
return makeConfigGeneralResult(), err return makeConfigGeneralResult(), err
} }

View File

@@ -128,6 +128,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
TranscodeOutputArgs: config.GetTranscodeOutputArgs(), TranscodeOutputArgs: config.GetTranscodeOutputArgs(),
LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(), LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(),
LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(), LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(),
DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(),
} }
} }

View File

@@ -185,6 +185,9 @@ const (
HandyKey = "handy_key" HandyKey = "handy_key"
FunscriptOffset = "funscript_offset" FunscriptOffset = "funscript_offset"
DrawFunscriptHeatmapRange = "draw_funscript_heatmap_range"
drawFunscriptHeatmapRangeDefault = true
ThemeColor = "theme_color" ThemeColor = "theme_color"
DefaultThemeColor = "#202b33" DefaultThemeColor = "#202b33"
@@ -831,6 +834,10 @@ func (i *Instance) GetLiveTranscodeOutputArgs() []string {
return i.getStringSlice(LiveTranscodeOutputArgs) return i.getStringSlice(LiveTranscodeOutputArgs)
} }
func (i *Instance) GetDrawFunscriptHeatmapRange() bool {
return i.getBoolDefault(DrawFunscriptHeatmapRange, drawFunscriptHeatmapRangeDefault)
}
// IsWriteImageThumbnails returns true if image thumbnails should be written // IsWriteImageThumbnails returns true if image thumbnails should be written
// to disk after generating on the fly. // to disk after generating on the fly.
func (i *Instance) IsWriteImageThumbnails() bool { func (i *Instance) IsWriteImageThumbnails() bool {

View File

@@ -15,14 +15,13 @@ import (
) )
type InteractiveHeatmapSpeedGenerator struct { type InteractiveHeatmapSpeedGenerator struct {
sceneDurationMilli int64 InteractiveSpeed int
InteractiveSpeed int Funscript Script
Funscript Script Width int
FunscriptPath string Height int
HeatmapPath string NumSegments int
Width int
Height int DrawRange bool
NumSegments int
} }
type Script struct { type Script struct {
@@ -33,8 +32,7 @@ type Script struct {
// Range is the percentage of a full stroke to use. // Range is the percentage of a full stroke to use.
Range int `json:"range,omitempty"` Range int `json:"range,omitempty"`
// Actions are the timed moves. // Actions are the timed moves.
Actions []Action `json:"actions"` Actions []Action `json:"actions"`
AvarageSpeed int64
} }
// Action is a move at a specific time. // Action is a move at a specific time.
@@ -50,23 +48,22 @@ type Action struct {
} }
type GradientTable []struct { type GradientTable []struct {
Col colorful.Color Col colorful.Color
Pos float64 Pos float64
YRange [2]float64
} }
func NewInteractiveHeatmapSpeedGenerator(funscriptPath string, heatmapPath string, sceneDuration float64) *InteractiveHeatmapSpeedGenerator { func NewInteractiveHeatmapSpeedGenerator(drawRange bool) *InteractiveHeatmapSpeedGenerator {
return &InteractiveHeatmapSpeedGenerator{ return &InteractiveHeatmapSpeedGenerator{
sceneDurationMilli: int64(sceneDuration * 1000), Width: 1280,
FunscriptPath: funscriptPath, Height: 60,
HeatmapPath: heatmapPath, NumSegments: 600,
Width: 320, DrawRange: drawRange,
Height: 15,
NumSegments: 150,
} }
} }
func (g *InteractiveHeatmapSpeedGenerator) Generate() error { func (g *InteractiveHeatmapSpeedGenerator) Generate(funscriptPath string, heatmapPath string, sceneDuration float64) error {
funscript, err := g.LoadFunscriptData(g.FunscriptPath) funscript, err := g.LoadFunscriptData(funscriptPath, sceneDuration)
if err != nil { if err != nil {
return err return err
@@ -79,7 +76,7 @@ func (g *InteractiveHeatmapSpeedGenerator) Generate() error {
g.Funscript = funscript g.Funscript = funscript
g.Funscript.UpdateIntensityAndSpeed() g.Funscript.UpdateIntensityAndSpeed()
err = g.RenderHeatmap() err = g.RenderHeatmap(heatmapPath)
if err != nil { if err != nil {
return err return err
@@ -90,7 +87,7 @@ func (g *InteractiveHeatmapSpeedGenerator) Generate() error {
return nil 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) data, err := os.ReadFile(path)
if err != nil { if err != nil {
return Script{}, err 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 // trim actions with negative timestamps to avoid index range errors when generating heatmap
// #3181 - also trim actions that occur after the scene duration // #3181 - also trim actions that occur after the scene duration
loggedBadTimestamp := false loggedBadTimestamp := false
sceneDurationMilli := int64(sceneDuration * 1000)
isValid := func(x int64) bool { isValid := func(x int64) bool {
return x >= 0 && x < g.sceneDurationMilli return x >= 0 && x < sceneDurationMilli
} }
i := 0 i := 0
@@ -157,14 +155,27 @@ func (funscript *Script) UpdateIntensityAndSpeed() {
} }
// funscript needs to have intensity updated first // 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) gradient := g.Funscript.getGradientTable(g.NumSegments)
img := image.NewRGBA(image.Rect(0, 0, g.Width, g.Height)) img := image.NewRGBA(image.Rect(0, 0, g.Width, g.Height))
for x := 0; x < g.Width; x++ { for x := 0; x < g.Width; x++ {
c := gradient.GetInterpolatedColorFor(float64(x) / float64(g.Width)) xPos := float64(x) / float64(g.Width)
draw.Draw(img, image.Rect(x, 0, x+1, g.Height), &image.Uniform{c}, image.Point{}, draw.Src) 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 // add 10 minute marks
@@ -178,7 +189,7 @@ func (g *InteractiveHeatmapSpeedGenerator) RenderHeatmap() error {
ts += tick ts += tick
} }
outpng, err := os.Create(g.HeatmapPath) outpng, err := os.Create(heatmapPath)
if err != nil { if err != nil {
return err return err
} }
@@ -217,27 +228,96 @@ func (gt GradientTable) GetInterpolatedColorFor(t float64) colorful.Color {
return gt[len(gt)-1].Col 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 { func (funscript Script) getGradientTable(numSegments int) GradientTable {
const windowSize = 15
const backfillThreshold = 500
segments := make([]struct { segments := make([]struct {
count int count int
intensity int intensity int
yRange [2]float64
at int64
}, numSegments) }, numSegments)
gradient := make(GradientTable, numSegments) gradient := make(GradientTable, numSegments)
posList := []int{}
maxts := funscript.Actions[len(funscript.Actions)-1].At maxts := funscript.Actions[len(funscript.Actions)-1].At
for _, a := range funscript.Actions { 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)) segment := int(float64(a.At) / float64(maxts+1) * float64(numSegments))
// #3181 - sanity check. Clamp segment to numSegments-1 // #3181 - sanity check. Clamp segment to numSegments-1
if segment >= numSegments { if segment >= numSegments {
segment = numSegments - 1 segment = numSegments - 1
} }
segments[segment].at = a.At
segments[segment].count++ segments[segment].count++
segments[segment].intensity += int(a.Intensity) 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++ { for i := 0; i < numSegments; i++ {
gradient[i].Pos = float64(i) / float64(numSegments-1) gradient[i].Pos = float64(i) / float64(numSegments-1)
gradient[i].YRange = segments[i].yRange
if segments[i].count > 0 { if segments[i].count > 0 {
gradient[i].Col = getSegmentColor(float64(segments[i].intensity) / float64(segments[i].count)) gradient[i].Col = getSegmentColor(float64(segments[i].intensity) / float64(segments[i].count))
} else { } else {

View File

@@ -29,10 +29,11 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) {
videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm) videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm)
funscriptPath := video.GetFunscriptPath(t.Scene.Path) funscriptPath := video.GetFunscriptPath(t.Scene.Path)
heatmapPath := instance.Paths.Scene.GetInteractiveHeatmapPath(videoChecksum) 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 { if err != nil {
logger.Errorf("error generating heatmap: %s", err.Error()) logger.Errorf("error generating heatmap: %s", err.Error())

View File

@@ -315,6 +315,16 @@ export const SettingsConfigurationPanel: React.FC = () => {
/> />
</SettingSection> </SettingSection>
<SettingSection headingID="config.general.heatmap_generation">
<BooleanSetting
id="heatmap-draw-range"
headingID="config.general.funscript_heatmap_draw_range"
subHeadingID="config.general.funscript_heatmap_draw_range_desc"
checked={general.drawFunscriptHeatmapRange ?? true}
onChange={(v) => saveGeneral({ drawFunscriptHeatmapRange: v })}
/>
</SettingSection>
<SettingSection headingID="config.general.logging"> <SettingSection headingID="config.general.logging">
<StringSetting <StringSetting
headingID="config.general.auth.log_file" headingID="config.general.auth.log_file"

View File

@@ -1,4 +1,5 @@
### ✨ New Features ### ✨ New Features
* Optionally show range in generated funscript heatmaps. ([#3373](https://github.com/stashapp/stash/pull/3373))
* Show funscript heatmaps in scene player scrubber. ([#3374](https://github.com/stashapp/stash/pull/3374)) * Show funscript heatmaps in scene player scrubber. ([#3374](https://github.com/stashapp/stash/pull/3374))
* Support customising the filename regex used for determining the gallery cover image. ([#3391](https://github.com/stashapp/stash/pull/3391)) * Support customising the filename regex used for determining the gallery cover image. ([#3391](https://github.com/stashapp/stash/pull/3391))
* Added tenth-place rating precision option. ([#3343](https://github.com/stashapp/stash/pull/3343)) * Added tenth-place rating precision option. ([#3343](https://github.com/stashapp/stash/pull/3343))

View File

@@ -296,6 +296,8 @@
} }
} }
}, },
"funscript_heatmap_draw_range": "Include range in generated heatmaps",
"funscript_heatmap_draw_range_desc": "Draw range of motion on the y-axis of the generated heatmap. Existing heatmaps will need to be regenerated after changing.",
"gallery_ext_desc": "Comma-delimited list of file extensions that will be identified as gallery zip files.", "gallery_ext_desc": "Comma-delimited list of file extensions that will be identified as gallery zip files.",
"gallery_ext_head": "Gallery zip Extensions", "gallery_ext_head": "Gallery zip Extensions",
"generated_file_naming_hash_desc": "Use MD5 or oshash for generated file naming. Changing this requires that all scenes have the applicable MD5/oshash value populated. After changing this value, existing generated files will need to be migrated or regenerated. See Tasks page for migration.", "generated_file_naming_hash_desc": "Use MD5 or oshash for generated file naming. Changing this requires that all scenes have the applicable MD5/oshash value populated. After changing this value, existing generated files will need to be migrated or regenerated. See Tasks page for migration.",
@@ -303,6 +305,7 @@
"generated_files_location": "Directory location for the generated files (scene markers, scene previews, sprites, etc)", "generated_files_location": "Directory location for the generated files (scene markers, scene previews, sprites, etc)",
"generated_path_head": "Generated Path", "generated_path_head": "Generated Path",
"hashing": "Hashing", "hashing": "Hashing",
"heatmap_generation": "Funscript Heatmap Generation",
"image_ext_desc": "Comma-delimited list of file extensions that will be identified as images.", "image_ext_desc": "Comma-delimited list of file extensions that will be identified as images.",
"image_ext_head": "Image Extensions", "image_ext_head": "Image Extensions",
"include_audio_desc": "Includes audio stream when generating previews.", "include_audio_desc": "Includes audio stream when generating previews.",