Selectable wall preview type (#510)

* Add optional image preview generation
* Add setting for video preview encoding preset
This commit is contained in:
InfiniteTF
2020-05-27 01:33:49 +02:00
committed by GitHub
parent 197918d13c
commit 4ec6d62e01
18 changed files with 499 additions and 347 deletions

View File

@@ -21,6 +21,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
fragment ConfigInterfaceData on ConfigInterfaceResult { fragment ConfigInterfaceData on ConfigInterfaceResult {
soundOnPreview soundOnPreview
wallShowTitle wallShowTitle
wallPlayback
maximumLoopDuration maximumLoopDuration
autostartVideo autostartVideo
showStudioAsText showStudioAsText

View File

@@ -86,6 +86,8 @@ input ConfigInterfaceInput {
soundOnPreview: Boolean soundOnPreview: Boolean
"""Show title and tags in wall view""" """Show title and tags in wall view"""
wallShowTitle: Boolean wallShowTitle: Boolean
"""Wall playback type"""
wallPlayback: String
"""Maximum duration (in seconds) in which a scene video will loop in the scene player""" """Maximum duration (in seconds) in which a scene video will loop in the scene player"""
maximumLoopDuration: Int maximumLoopDuration: Int
"""If true, video will autostart on load in the scene player""" """If true, video will autostart on load in the scene player"""
@@ -103,6 +105,8 @@ type ConfigInterfaceResult {
soundOnPreview: Boolean soundOnPreview: Boolean
"""Show title and tags in wall view""" """Show title and tags in wall view"""
wallShowTitle: Boolean wallShowTitle: Boolean
"""Wall playback type"""
wallPlayback: String
"""Maximum duration (in seconds) in which a scene video will loop in the scene player""" """Maximum duration (in seconds) in which a scene video will loop in the scene player"""
maximumLoopDuration: Int maximumLoopDuration: Int
"""If true, video will autostart on load in the scene player""" """If true, video will autostart on load in the scene player"""

View File

@@ -1,6 +1,8 @@
input GenerateMetadataInput { input GenerateMetadataInput {
sprites: Boolean! sprites: Boolean!
previews: Boolean! previews: Boolean!
previewPreset: PreviewPreset
imagePreviews: Boolean!
markers: Boolean! markers: Boolean!
transcodes: Boolean! transcodes: Boolean!
"""gallery thumbnails for cache usage""" """gallery thumbnails for cache usage"""
@@ -25,3 +27,13 @@ type MetadataUpdateStatus {
status: String! status: String!
message: String! message: String!
} }
enum PreviewPreset {
"X264_ULTRAFAST", ultrafast
"X264_VERYFAST", veryfast
"X264_FAST", fast
"X264_MEDIUM", medium
"X264_SLOW", slow
"X264_SLOWER", slower
"X264_VERYSLOW", veryslow
}

View File

@@ -111,6 +111,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
config.Set(config.WallShowTitle, *input.WallShowTitle) config.Set(config.WallShowTitle, *input.WallShowTitle)
} }
if input.WallPlayback != nil {
config.Set(config.WallPlayback, *input.WallPlayback)
}
if input.MaximumLoopDuration != nil { if input.MaximumLoopDuration != nil {
config.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration) config.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration)
} }

View File

@@ -23,7 +23,7 @@ func (r *mutationResolver) MetadataExport(ctx context.Context) (string, error) {
} }
func (r *mutationResolver) MetadataGenerate(ctx context.Context, input models.GenerateMetadataInput) (string, error) { func (r *mutationResolver) MetadataGenerate(ctx context.Context, input models.GenerateMetadataInput) (string, error) {
manager.GetInstance().Generate(input.Sprites, input.Previews, input.Markers, input.Transcodes, input.Thumbnails) manager.GetInstance().Generate(input.Sprites, input.Previews, input.PreviewPreset, input.ImagePreviews, input.Markers, input.Transcodes, input.Thumbnails)
return "todo", nil return "todo", nil
} }

View File

@@ -65,6 +65,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
func makeConfigInterfaceResult() *models.ConfigInterfaceResult { func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
soundOnPreview := config.GetSoundOnPreview() soundOnPreview := config.GetSoundOnPreview()
wallShowTitle := config.GetWallShowTitle() wallShowTitle := config.GetWallShowTitle()
wallPlayback := config.GetWallPlayback()
maximumLoopDuration := config.GetMaximumLoopDuration() maximumLoopDuration := config.GetMaximumLoopDuration()
autostartVideo := config.GetAutostartVideo() autostartVideo := config.GetAutostartVideo()
showStudioAsText := config.GetShowStudioAsText() showStudioAsText := config.GetShowStudioAsText()
@@ -75,6 +76,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
return &models.ConfigInterfaceResult{ return &models.ConfigInterfaceResult{
SoundOnPreview: &soundOnPreview, SoundOnPreview: &soundOnPreview,
WallShowTitle: &wallShowTitle, WallShowTitle: &wallShowTitle,
WallPlayback: &wallPlayback,
MaximumLoopDuration: &maximumLoopDuration, MaximumLoopDuration: &maximumLoopDuration,
AutostartVideo: &autostartVideo, AutostartVideo: &autostartVideo,
ShowStudioAsText: &showStudioAsText, ShowStudioAsText: &showStudioAsText,

View File

@@ -13,9 +13,10 @@ type ScenePreviewChunkOptions struct {
OutputPath string OutputPath string
} }
func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePreviewChunkOptions) { func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePreviewChunkOptions, preset string) {
args := []string{ args := []string{
"-v", "error", "-v", "error",
"-xerror",
"-ss", strconv.Itoa(options.Time), "-ss", strconv.Itoa(options.Time),
"-i", probeResult.Path, "-i", probeResult.Path,
"-t", "0.75", "-t", "0.75",
@@ -25,7 +26,7 @@ func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePre
"-pix_fmt", "yuv420p", "-pix_fmt", "yuv420p",
"-profile:v", "high", "-profile:v", "high",
"-level", "4.2", "-level", "4.2",
"-preset", "slow", "-preset", preset,
"-crf", "21", "-crf", "21",
"-threads", "4", "-threads", "4",
"-vf", fmt.Sprintf("scale=%v:-2", options.Width), "-vf", fmt.Sprintf("scale=%v:-2", options.Width),

View File

@@ -11,6 +11,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager/config" "github.com/stashapp/stash/pkg/manager/config"
) )
@@ -264,7 +265,11 @@ func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
result.Container = probeJSON.Format.FormatName result.Container = probeJSON.Format.FormatName
duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64) duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64)
result.Duration = math.Round(duration*100) / 100 result.Duration = math.Round(duration*100) / 100
fileStat, _ := os.Stat(filePath) fileStat, err := os.Stat(filePath)
if err != nil {
logger.Errorf("Error statting file: %v", err)
return nil, err
}
result.Size = fileStat.Size() result.Size = fileStat.Size()
result.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64) result.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64)
result.CreationTime = probeJSON.Format.Tags.CreationTime.Time result.CreationTime = probeJSON.Format.Tags.CreationTime.Time

View File

@@ -54,6 +54,7 @@ const MaximumLoopDuration = "maximum_loop_duration"
const AutostartVideo = "autostart_video" const AutostartVideo = "autostart_video"
const ShowStudioAsText = "show_studio_as_text" const ShowStudioAsText = "show_studio_as_text"
const CSSEnabled = "cssEnabled" const CSSEnabled = "cssEnabled"
const WallPlayback = "wall_playback"
// Playback force codec,container // Playback force codec,container
const ForceMKV = "forceMKV" const ForceMKV = "forceMKV"
@@ -241,6 +242,11 @@ func GetWallShowTitle() bool {
return viper.GetBool(WallShowTitle) return viper.GetBool(WallShowTitle)
} }
func GetWallPlayback() string {
viper.SetDefault(WallPlayback, "video")
return viper.GetString(WallPlayback)
}
func GetMaximumLoopDuration() int { func GetMaximumLoopDuration() int {
viper.SetDefault(MaximumLoopDuration, 0) viper.SetDefault(MaximumLoopDuration, 0)
return viper.GetInt(MaximumLoopDuration) return viper.GetInt(MaximumLoopDuration)

View File

@@ -16,9 +16,14 @@ type PreviewGenerator struct {
VideoFilename string VideoFilename string
ImageFilename string ImageFilename string
OutputDirectory string OutputDirectory string
GenerateVideo bool
GenerateImage bool
PreviewPreset string
} }
func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoFilename string, imageFilename string, outputDirectory string) (*PreviewGenerator, error) { func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoFilename string, imageFilename string, outputDirectory string, generateVideo bool, generateImage bool, previewPreset string) (*PreviewGenerator, error) {
exists, err := utils.FileExists(videoFile.Path) exists, err := utils.FileExists(videoFile.Path)
if !exists { if !exists {
return nil, err return nil, err
@@ -37,6 +42,9 @@ func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoFilename string, image
VideoFilename: videoFilename, VideoFilename: videoFilename,
ImageFilename: imageFilename, ImageFilename: imageFilename,
OutputDirectory: outputDirectory, OutputDirectory: outputDirectory,
GenerateVideo: generateVideo,
GenerateImage: generateImage,
PreviewPreset: previewPreset,
}, nil }, nil
} }
@@ -47,12 +55,17 @@ func (g *PreviewGenerator) Generate() error {
if err := g.generateConcatFile(); err != nil { if err := g.generateConcatFile(); err != nil {
return err return err
} }
if g.GenerateVideo {
if err := g.generateVideo(&encoder); err != nil { if err := g.generateVideo(&encoder); err != nil {
return err return err
} }
}
if g.GenerateImage {
if err := g.generateImage(&encoder); err != nil { if err := g.generateImage(&encoder); err != nil {
return err return err
} }
}
return nil return nil
} }
@@ -91,7 +104,7 @@ func (g *PreviewGenerator) generateVideo(encoder *ffmpeg.Encoder) error {
Width: 640, Width: 640,
OutputPath: chunkOutputPath, OutputPath: chunkOutputPath,
} }
encoder.ScenePreviewVideoChunk(g.Info.VideoFile, options) encoder.ScenePreviewVideoChunk(g.Info.VideoFile, options, g.PreviewPreset)
} }
videoOutputPath := filepath.Join(g.OutputDirectory, g.VideoFilename) videoOutputPath := filepath.Join(g.OutputDirectory, g.VideoFilename)

View File

@@ -171,7 +171,7 @@ func (s *singleton) Export() {
}() }()
} }
func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcodes bool, thumbnails bool) { func (s *singleton) Generate(sprites bool, previews bool, previewPreset *models.PreviewPreset, imagePreviews bool, markers bool, transcodes bool, thumbnails bool) {
if s.Status.Status != Idle { if s.Status.Status != Idle {
return return
} }
@@ -183,6 +183,11 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod
//this.job.total = await ObjectionUtils.getCount(Scene); //this.job.total = await ObjectionUtils.getCount(Scene);
instance.Paths.Generated.EnsureTmpDir() instance.Paths.Generated.EnsureTmpDir()
preset := string(models.PreviewPresetSlow)
if previewPreset != nil && previewPreset.IsValid() {
preset = string(*previewPreset)
}
go func() { go func() {
defer s.returnToIdleState() defer s.returnToIdleState()
@@ -212,12 +217,12 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod
logger.Info("Stopping due to user request") logger.Info("Stopping due to user request")
return return
} }
totalsNeeded := s.neededGenerate(scenes, sprites, previews, markers, transcodes) totalsNeeded := s.neededGenerate(scenes, sprites, previews, imagePreviews, markers, transcodes)
if totalsNeeded == nil { if totalsNeeded == nil {
logger.Infof("Taking too long to count content. Skipping...") logger.Infof("Taking too long to count content. Skipping...")
logger.Infof("Generating content") logger.Infof("Generating content")
} else { } else {
logger.Infof("Generating %d sprites %d previews %d markers %d transcodes", totalsNeeded.sprites, totalsNeeded.previews, totalsNeeded.markers, totalsNeeded.transcodes) logger.Infof("Generating %d sprites %d previews %d image previews %d markers %d transcodes", totalsNeeded.sprites, totalsNeeded.previews, totalsNeeded.imagePreviews, totalsNeeded.markers, totalsNeeded.transcodes)
} }
for i, scene := range scenes { for i, scene := range scenes {
s.Status.setProgress(i, total) s.Status.setProgress(i, total)
@@ -244,7 +249,7 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod
} }
if previews { if previews {
task := GeneratePreviewTask{Scene: *scene} task := GeneratePreviewTask{Scene: *scene, ImagePreview: imagePreviews, PreviewPreset: preset}
go task.Start(&wg) go task.Start(&wg)
} }
@@ -604,11 +609,12 @@ func (s *singleton) neededScan(paths []string) int64 {
type totalsGenerate struct { type totalsGenerate struct {
sprites int64 sprites int64
previews int64 previews int64
imagePreviews int64
markers int64 markers int64
transcodes int64 transcodes int64
} }
func (s *singleton) neededGenerate(scenes []*models.Scene, sprites, previews, markers, transcodes bool) *totalsGenerate { func (s *singleton) neededGenerate(scenes []*models.Scene, sprites, previews, imagePreviews, markers, transcodes bool) *totalsGenerate {
var totals totalsGenerate var totals totalsGenerate
const timeout = 90 * time.Second const timeout = 90 * time.Second
@@ -633,10 +639,13 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, sprites, previews, ma
} }
if previews { if previews {
task := GeneratePreviewTask{Scene: *scene} task := GeneratePreviewTask{Scene: *scene, ImagePreview: imagePreviews}
if !task.doesPreviewExist(task.Scene.Checksum) { if !task.doesVideoPreviewExist(task.Scene.Checksum) {
totals.previews++ totals.previews++
} }
if imagePreviews && !task.doesImagePreviewExist(task.Scene.Checksum) {
totals.imagePreviews++
}
} }
if markers { if markers {

View File

@@ -10,6 +10,8 @@ import (
type GeneratePreviewTask struct { type GeneratePreviewTask struct {
Scene models.Scene Scene models.Scene
ImagePreview bool
PreviewPreset string
} }
func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) { func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
@@ -17,7 +19,8 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
videoFilename := t.videoFilename() videoFilename := t.videoFilename()
imageFilename := t.imageFilename() imageFilename := t.imageFilename()
if t.doesPreviewExist(t.Scene.Checksum) { videoExists := t.doesVideoPreviewExist(t.Scene.Checksum)
if (!t.ImagePreview || t.doesImagePreviewExist(t.Scene.Checksum)) && videoExists {
return return
} }
@@ -27,7 +30,7 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
return return
} }
generator, err := NewPreviewGenerator(*videoFile, videoFilename, imageFilename, instance.Paths.Generated.Screenshots) generator, err := NewPreviewGenerator(*videoFile, videoFilename, imageFilename, instance.Paths.Generated.Screenshots, !videoExists, t.ImagePreview, t.PreviewPreset)
if err != nil { if err != nil {
logger.Errorf("error creating preview generator: %s", err.Error()) logger.Errorf("error creating preview generator: %s", err.Error())
return return
@@ -39,10 +42,14 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) {
} }
} }
func (t *GeneratePreviewTask) doesPreviewExist(sceneChecksum string) bool { func (t *GeneratePreviewTask) doesVideoPreviewExist(sceneChecksum string) bool {
videoExists, _ := utils.FileExists(instance.Paths.Scene.GetStreamPreviewPath(sceneChecksum)) videoExists, _ := utils.FileExists(instance.Paths.Scene.GetStreamPreviewPath(sceneChecksum))
return videoExists
}
func (t *GeneratePreviewTask) doesImagePreviewExist(sceneChecksum string) bool {
imageExists, _ := utils.FileExists(instance.Paths.Scene.GetStreamPreviewImagePath(sceneChecksum)) imageExists, _ := utils.FileExists(instance.Paths.Scene.GetStreamPreviewImagePath(sceneChecksum))
return videoExists && imageExists return imageExists
} }
func (t *GeneratePreviewTask) videoFilename() string { func (t *GeneratePreviewTask) videoFilename() string {

View File

@@ -28,11 +28,8 @@ func FileExists(path string) (bool, error) {
_, err := os.Stat(path) _, err := os.Stat(path)
if err == nil { if err == nil {
return true, nil return true, nil
} else if os.IsNotExist(err) {
return false, err
} else {
panic(err)
} }
return false, err
} }
// DirExists returns true if the given path exists and is a directory // DirExists returns true if the given path exists and is a directory

View File

@@ -9,6 +9,7 @@ export const SettingsInterfacePanel: React.FC = () => {
const { data: config, error, loading } = useConfiguration(); const { data: config, error, loading } = useConfiguration();
const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true); const [soundOnPreview, setSoundOnPreview] = useState<boolean>(true);
const [wallShowTitle, setWallShowTitle] = useState<boolean>(true); const [wallShowTitle, setWallShowTitle] = useState<boolean>(true);
const [wallPlayback, setWallPlayback] = useState<string>("video");
const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0); const [maximumLoopDuration, setMaximumLoopDuration] = useState<number>(0);
const [autostartVideo, setAutostartVideo] = useState<boolean>(false); const [autostartVideo, setAutostartVideo] = useState<boolean>(false);
const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false); const [showStudioAsText, setShowStudioAsText] = useState<boolean>(false);
@@ -19,6 +20,7 @@ export const SettingsInterfacePanel: React.FC = () => {
const [updateInterfaceConfig] = useConfigureInterface({ const [updateInterfaceConfig] = useConfigureInterface({
soundOnPreview, soundOnPreview,
wallShowTitle, wallShowTitle,
wallPlayback,
maximumLoopDuration, maximumLoopDuration,
autostartVideo, autostartVideo,
showStudioAsText, showStudioAsText,
@@ -31,6 +33,7 @@ export const SettingsInterfacePanel: React.FC = () => {
const iCfg = config?.configuration?.interface; const iCfg = config?.configuration?.interface;
setSoundOnPreview(iCfg?.soundOnPreview ?? true); setSoundOnPreview(iCfg?.soundOnPreview ?? true);
setWallShowTitle(iCfg?.wallShowTitle ?? true); setWallShowTitle(iCfg?.wallShowTitle ?? true);
setWallPlayback(iCfg?.wallPlayback ?? "video");
setMaximumLoopDuration(iCfg?.maximumLoopDuration ?? 0); setMaximumLoopDuration(iCfg?.maximumLoopDuration ?? 0);
setAutostartVideo(iCfg?.autostartVideo ?? false); setAutostartVideo(iCfg?.autostartVideo ?? false);
setShowStudioAsText(iCfg?.showStudioAsText ?? false); setShowStudioAsText(iCfg?.showStudioAsText ?? false);
@@ -85,6 +88,22 @@ export const SettingsInterfacePanel: React.FC = () => {
label="Enable sound" label="Enable sound"
onChange={() => setSoundOnPreview(!soundOnPreview)} onChange={() => setSoundOnPreview(!soundOnPreview)}
/> />
<Form.Label htmlFor="wall-preview">
<h6>Preview Type</h6>
</Form.Label>
<Form.Control
as="select"
name="wall-preview"
className="col-4 input-control"
value={wallPlayback}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setWallPlayback(e.currentTarget.value)
}
>
<option value="video">Video</option>
<option value="animation">Animated Image</option>
<option value="image">Static Image</option>
</Form.Control>
<Form.Text className="text-muted"> <Form.Text className="text-muted">
Configuration for wall items Configuration for wall items
</Form.Text> </Form.Text>

View File

@@ -1,6 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Form } from "react-bootstrap"; import { Button, Form } from "react-bootstrap";
import { mutateMetadataGenerate } from "src/core/StashService"; import { mutateMetadataGenerate } from "src/core/StashService";
import { PreviewPreset } from "src/core/generated-graphql";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
export const GenerateButton: React.FC = () => { export const GenerateButton: React.FC = () => {
@@ -10,15 +11,21 @@ export const GenerateButton: React.FC = () => {
const [markers, setMarkers] = useState(true); const [markers, setMarkers] = useState(true);
const [transcodes, setTranscodes] = useState(false); const [transcodes, setTranscodes] = useState(false);
const [thumbnails, setThumbnails] = useState(false); const [thumbnails, setThumbnails] = useState(false);
const [imagePreviews, setImagePreviews] = useState(false);
const [previewPreset, setPreviewPreset] = useState<string>(
PreviewPreset.Slow
);
async function onGenerate() { async function onGenerate() {
try { try {
await mutateMetadataGenerate({ await mutateMetadataGenerate({
sprites, sprites,
previews, previews,
imagePreviews: previews && imagePreviews,
markers, markers,
transcodes, transcodes,
thumbnails, thumbnails,
previewPreset: (previewPreset as PreviewPreset) ?? undefined,
}); });
Toast.success({ content: "Started generating" }); Toast.success({ content: "Started generating" });
} catch (e) { } catch (e) {
@@ -29,18 +36,54 @@ export const GenerateButton: React.FC = () => {
return ( return (
<> <>
<Form.Group> <Form.Group>
<Form.Check
id="sprite-task"
checked={sprites}
label="Sprites (for the scene scrubber)"
onChange={() => setSprites(!sprites)}
/>
<Form.Check <Form.Check
id="preview-task" id="preview-task"
checked={previews} checked={previews}
label="Previews (video previews which play when hovering over a scene)" label="Previews (video previews which play when hovering over a scene)"
onChange={() => setPreviews(!previews)} onChange={() => setPreviews(!previews)}
/> />
<div className="d-flex flex-row">
<div></div>
<Form.Check
id="image-preview-task"
checked={imagePreviews}
disabled={!previews}
label="Image Previews (animated WebP previews, only required if Preview Type is set to Animated Image)"
onChange={() => setImagePreviews(!imagePreviews)}
className="ml-2 flex-grow"
/>
</div>
<Form.Group controlId="preview-preset" className="mt-2">
<Form.Label>
<h6>Preview encoding preset</h6>
</Form.Label>
<Form.Control
as="select"
value={previewPreset}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
setPreviewPreset(e.currentTarget.value)
}
disabled={!previews}
className="col-1"
>
{Object.keys(PreviewPreset).map((p) => (
<option value={p.toLowerCase()} key={p}>
{p}
</option>
))}
</Form.Control>
<Form.Text className="text-muted">
The preset regulates size, quality and encoding time of preview
generation. Presets beyond slow have diminishing returns and are
not recommended.
</Form.Text>
</Form.Group>
<Form.Check
id="sprite-task"
checked={sprites}
label="Sprites (for the scene scrubber)"
onChange={() => setSprites(!sprites)}
/>
<Form.Check <Form.Check
id="marker-task" id="marker-task"
checked={markers} checked={markers}

View File

@@ -1,64 +1,139 @@
import _ from "lodash";
import React, { useRef, useState, useEffect } from "react"; import React, { useRef, useState, useEffect } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { useConfiguration } from "src/core/StashService"; import { useConfiguration } from "src/core/StashService";
import { useVideoHover } from "src/hooks";
import { TextUtils, NavUtils } from "src/utils"; import { TextUtils, NavUtils } from "src/utils";
import cx from "classnames";
interface IWallItemProps { interface IWallItemProps {
scene?: GQL.SlimSceneDataFragment; scene?: GQL.SlimSceneDataFragment;
sceneMarker?: GQL.SceneMarkerDataFragment; sceneMarker?: GQL.SceneMarkerDataFragment;
origin?: string;
onOverlay: (show: boolean) => void;
clickHandler?: ( clickHandler?: (
item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment
) => void; ) => void;
className: string;
} }
interface IPreviews {
video?: string;
animation?: string;
image?: string;
}
const Preview: React.FC<{
previews?: IPreviews;
config?: GQL.ConfigDataFragment;
active: boolean;
}> = ({ previews, config, active }) => {
const videoElement = useRef() as React.MutableRefObject<HTMLVideoElement>;
const [isMissing, setIsMissing] = useState(false);
const previewType = config?.interface?.wallPlayback;
const soundOnPreview = config?.interface?.soundOnPreview ?? false;
useEffect(() => {
if (!videoElement.current) return;
videoElement.current.muted = !(soundOnPreview && active);
if (previewType !== "video") {
if (active) videoElement.current.play();
else videoElement.current.pause();
}
}, [videoElement, previewType, soundOnPreview, active]);
if (!previews) return <div />;
if (isMissing) {
return (
<div className="wall-item-media wall-item-missing">
Pending preview generation
</div>
);
}
const image = (
<img
alt=""
className="wall-item-media"
src={
(previewType === "animation" && previews.animation) || previews.image
}
/>
);
const video = (
<video
src={previews.video}
poster={previews.image}
autoPlay={previewType === "video"}
loop
muted
className={cx("wall-item-media", {
"wall-item-preview": previewType !== "video",
})}
onError={() => setIsMissing(true)}
ref={videoElement}
/>
);
if (previewType === "video") {
return video;
}
return (
<>
{image}
{video}
</>
);
};
export const WallItem: React.FC<IWallItemProps> = (props: IWallItemProps) => { export const WallItem: React.FC<IWallItemProps> = (props: IWallItemProps) => {
const [videoPath, setVideoPath] = useState<string>(); const [active, setActive] = useState(false);
const [previewPath, setPreviewPath] = useState<string>(""); const wallItem = useRef() as React.MutableRefObject<HTMLDivElement>;
const [screenshotPath, setScreenshotPath] = useState<string>("");
const [title, setTitle] = useState<string>("");
const [tags, setTags] = useState<JSX.Element[]>([]);
const config = useConfiguration(); const config = useConfiguration();
const hoverHandler = useVideoHover({
resetOnMouseLeave: true,
});
const showTextContainer = const showTextContainer =
config.data?.configuration.interface.wallShowTitle ?? true; config.data?.configuration.interface.wallShowTitle ?? true;
function onMouseEnter() { const previews = props.sceneMarker
hoverHandler.onMouseEnter(); ? {
if (!videoPath || videoPath === "") { video: props.sceneMarker.stream,
animation: props.sceneMarker.preview,
}
: {
video: props.scene?.paths.preview ?? undefined,
animation: props.scene?.paths.webp ?? undefined,
image: props.scene?.paths.screenshot ?? undefined,
};
const setInactive = () => setActive(false);
const toggleActive = (e: TransitionEvent) => {
if (e.propertyName === "transform" && e.elapsedTime === 0) {
// Get the current scale of the wall-item. If it's smaller than 1.1 the item is being scaled up, otherwise down.
const matrixScale = getComputedStyle(wallItem.current).transform.match(
/-?\d+\.?\d+|\d+/g
)?.[0];
const scale = Number.parseFloat(matrixScale ?? "2") || 2;
setActive(scale <= 1.1 && !active);
}
};
useEffect(() => {
const { current } = wallItem;
current?.addEventListener("transitioncancel", setInactive);
current?.addEventListener("transitionstart", toggleActive);
return () => {
current?.removeEventListener("transitioncancel", setInactive);
current?.removeEventListener("transitionstart", toggleActive);
};
});
const clickHandler = () => {
if (props.scene) {
props?.clickHandler?.(props.scene);
}
if (props.sceneMarker) { if (props.sceneMarker) {
setVideoPath(props.sceneMarker.stream || ""); props?.clickHandler?.(props.sceneMarker);
} else if (props.scene) {
setVideoPath(props.scene.paths.preview || "");
}
}
props.onOverlay(true);
}
const debouncedOnMouseEnter = useRef(_.debounce(onMouseEnter, 500));
function onMouseLeave() {
hoverHandler.onMouseLeave();
setVideoPath("");
debouncedOnMouseEnter.current.cancel();
props.onOverlay(false);
}
function onClick() {
if (props.clickHandler === undefined) {
return;
}
if (props.scene !== undefined) {
props.clickHandler(props.scene);
} else if (props.sceneMarker !== undefined) {
props.clickHandler(props.sceneMarker);
}
} }
};
let linkSrc: string = "#"; let linkSrc: string = "#";
if (!props.clickHandler) { if (!props.clickHandler) {
@@ -69,89 +144,40 @@ export const WallItem: React.FC<IWallItemProps> = (props: IWallItemProps) => {
} }
} }
function onTransitionEnd(event: React.TransitionEvent<HTMLDivElement>) { const renderText = () => {
const target = event.currentTarget; if (!showTextContainer) return;
if (target.classList.contains("double-scale") && target.parentElement) {
target.parentElement.style.zIndex = "10";
} else if (target.parentElement) {
target.parentElement.style.zIndex = "";
}
}
useEffect(() => { const title = props.sceneMarker
if (props.sceneMarker) { ? `${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(
setPreviewPath(props.sceneMarker.preview);
setTitle(
`${props.sceneMarker!.title} - ${TextUtils.secondsToTimestamp(
props.sceneMarker.seconds props.sceneMarker.seconds
)}` )}`
); : props.scene?.title ?? "";
const thisTags = props.sceneMarker.tags.map((tag) => ( const tags = props.sceneMarker
? [props.sceneMarker.primary_tag, ...props.sceneMarker.tags]
: [];
return (
<div className="wall-item-text">
<div>{title}</div>
{tags.map((tag) => (
<span key={tag.id} className="wall-tag"> <span key={tag.id} className="wall-tag">
{tag.name} {tag.name}
</span> </span>
)); ))}
thisTags.unshift( </div>
<span key={props.sceneMarker.primary_tag.id} className="wall-tag">
{props.sceneMarker.primary_tag.name}
</span>
); );
setTags(thisTags); };
} else if (props.scene) {
setPreviewPath(props.scene.paths.webp || "");
setScreenshotPath(props.scene.paths.screenshot || "");
setTitle(props.scene.title || "");
}
}, [props.sceneMarker, props.scene]);
function previewNotFound() {
if (previewPath !== screenshotPath) {
setPreviewPath(screenshotPath);
}
}
const className = ["scene-wall-item-container"];
if (hoverHandler.isHovering.current) {
className.push("double-scale");
}
const style: React.CSSProperties = {};
if (props.origin) {
style.transformOrigin = props.origin;
}
return ( return (
<div className="wall-item"> <div className="wall-item">
<div <div className={`wall-item-container ${props.className}`} ref={wallItem}>
className={className.join(" ")} <Link onClick={clickHandler} to={linkSrc} className="wall-item-anchor">
style={style} <Preview
onTransitionEnd={onTransitionEnd} previews={previews}
onMouseEnter={() => debouncedOnMouseEnter.current()} config={config.data?.configuration}
onMouseMove={() => debouncedOnMouseEnter.current()} active={active}
onMouseLeave={onMouseLeave}
>
<Link onClick={onClick} to={linkSrc}>
<video
src={videoPath}
poster={screenshotPath}
className="scene-wall-video"
style={hoverHandler.isHovering.current ? {} : { display: "none" }}
autoPlay
loop
ref={hoverHandler.videoEl}
/> />
<img {renderText()}
alt={title}
className="scene-wall-image"
src={previewPath || screenshotPath}
onError={() => previewNotFound()}
/>
{showTextContainer ? (
<div className="scene-wall-item-text-container">
<div>{title}</div>
{tags}
</div>
) : (
""
)}
</Link> </Link>
</div> </div>
</div> </div>

View File

@@ -1,4 +1,4 @@
import React, { useState } from "react"; import React from "react";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { WallItem } from "./WallItem"; import { WallItem } from "./WallItem";
@@ -10,106 +10,58 @@ interface IWallPanelProps {
) => void; ) => void;
} }
const calculateClass = (index: number, count: number) => {
// First position and more than one row
if (index === 0 && count > 5) return "transform-origin-top-left";
// Fifth position and more than one row
if (index === 4 && count > 5) return "transform-origin-top-right";
// Top row
if (index < 5) return "transform-origin-top";
// Two or more rows, with full last row and index is last
if (count > 9 && count % 5 === 0 && index + 1 === count)
return "transform-origin-bottom-right";
// Two or more rows, with full last row and index is fifth to last
if (count > 9 && count % 5 === 0 && index + 5 === count)
return "transform-origin-bottom-left";
// Multiple of five minus one
if (index % 5 === 4) return "transform-origin-right";
// Multiple of five
if (index % 5 === 0) return "transform-origin-left";
// Position is equal or larger than first postion in last row
if (count - (count % 5 || 5) <= index + 1) return "transform-origin-bottom";
// Default
return "transform-origin-center";
};
export const WallPanel: React.FC<IWallPanelProps> = ( export const WallPanel: React.FC<IWallPanelProps> = (
props: IWallPanelProps props: IWallPanelProps
) => { ) => {
const [showOverlay, setShowOverlay] = useState<boolean>(false); const scenes = (props.scenes ?? []).map((scene, index, sceneArray) => (
function onOverlay(show: boolean) {
setShowOverlay(show);
}
function getOrigin(index: number, rowSize: number, total: number): string {
const isAtStart = index % rowSize === 0;
const isAtEnd = index % rowSize === rowSize - 1;
const endRemaining = total % rowSize;
// First row
if (total === 1) {
return "top";
}
if (index === 0) {
return "top left";
}
if (index === rowSize - 1 || (total < rowSize && index === total - 1)) {
return "top right";
}
if (index < rowSize) {
return "top";
}
// Bottom row
if (isAtEnd && index === total - 1) {
return "bottom right";
}
if (isAtStart && index === total - rowSize) {
return "bottom left";
}
if (endRemaining !== 0 && index >= total - endRemaining) {
return "bottom";
}
if (endRemaining === 0 && index >= total - rowSize) {
return "bottom";
}
// Everything else
if (isAtStart) {
return "center left";
}
if (isAtEnd) {
return "center right";
}
return "center";
}
function maybeRenderScenes() {
if (props.scenes === undefined) {
return;
}
return props.scenes.map((scene, index) => {
const origin = getOrigin(index, 5, props.scenes!.length);
return (
<WallItem <WallItem
key={scene.id} key={scene.id}
scene={scene} scene={scene}
onOverlay={onOverlay}
clickHandler={props.clickHandler} clickHandler={props.clickHandler}
origin={origin} className={calculateClass(index, sceneArray.length)}
/> />
); ));
});
}
function maybeRenderSceneMarkers() { const sceneMarkers = (
if (props.sceneMarkers === undefined) { props.sceneMarkers ?? []
return; ).map((marker, index, markerArray) => (
}
return props.sceneMarkers.map((marker, index) => {
const origin = getOrigin(index, 5, props.sceneMarkers!.length);
return (
<WallItem <WallItem
key={marker.id} key={marker.id}
sceneMarker={marker} sceneMarker={marker}
onOverlay={onOverlay}
clickHandler={props.clickHandler} clickHandler={props.clickHandler}
origin={origin} className={calculateClass(index, markerArray.length)}
/> />
); ));
});
}
function render() {
const overlayClassName = showOverlay ? "visible" : "hidden";
return ( return (
<> <div className="row">
<div className={`wall-overlay ${overlayClassName}`} /> <div className="wall w-100 row justify-content-center">
<div className="wall row justify-content-center"> {scenes}
{maybeRenderScenes()} {sceneMarkers}
{maybeRenderSceneMarkers()} </div>
</div> </div>
</>
); );
}
return render();
}; };

View File

@@ -1,69 +1,54 @@
.wall-overlay { .wall {
background-color: rgba(0, 0, 0, 0.8); margin: 0 auto;
bottom: 0; max-width: 2250px;
left: 0;
pointer-events: none; &-item {
position: fixed; height: 11.25vw;
right: 0; line-height: 0;
top: 0; max-height: 253px;
transition: transform 0.5s ease-in-out; max-width: 450px;
z-index: 1; overflow: visible;
padding: 0;
transition: z-index 0.5s 0.5s;
width: 20%;
z-index: 0;
@media (max-width: 576px) {
height: inherit;
max-width: 100%;
width: 100%;
} }
.visible { &-anchor:hover {
opacity: 1; text-decoration: none;
transition: opacity 0.5s ease-in-out;
} }
.hidden { &-media {
opacity: 0;
transition: opacity 0.5s ease-in-out;
}
.visible-unanimated {
opacity: 1;
}
.hidden-unanimated {
opacity: 0;
}
.double-scale {
background-color: black; background-color: black;
position: absolute;
transform: scale(2);
z-index: 2;
}
.double-scale .scene-wall-image {
opacity: 0;
}
.scene-wall-video,
.scene-wall-image {
height: 100%; height: 100%;
object-fit: contain; object-fit: contain;
transition: z-index 0s 0s;
width: 100%; width: 100%;
z-index: 0;
} }
.scene-wall-item-container { &-missing {
align-items: center;
color: $text-color;
display: flex; display: flex;
height: 100%; font-size: 1.5rem;
justify-content: center; justify-content: center;
max-height: 253px;
position: relative;
transition: transform 0.5s;
width: 100%;
} }
.scene-wall-item-container .scene-wall-video { &-preview {
height: 100%; left: 0;
position: absolute; position: absolute;
width: 100%; top: 0;
transition: z-index 0s 0s;
z-index: -1; z-index: -1;
} }
.scene-wall-item-text-container { &-text {
background: linear-gradient( background: linear-gradient(
rgba(255, 255, 255, 0.25), rgba(255, 255, 255, 0.25),
rgba(255, 255, 255, 0.65) rgba(255, 255, 255, 0.65)
@@ -78,6 +63,7 @@
position: absolute; position: absolute;
text-align: center; text-align: center;
width: 100%; width: 100%;
z-index: 2000000;
.wall-tag { .wall-tag {
font-size: 10px; font-size: 10px;
@@ -87,25 +73,90 @@
} }
} }
.scene-wall-item-blur { &-container {
bottom: -5px; background-color: black;
left: -5px; display: flex;
position: absolute; height: 100%;
right: -5px; justify-content: center;
top: -5px; position: relative;
transition: all 0.5s 0s;
width: 100%;
z-index: 0;
}
&-container.transform-origin-top-left {
transform-origin: top left;
}
&-container.transform-origin-top-right {
transform-origin: top right;
}
&-container.transform-origin-bottom-left {
transform-origin: bottom left;
}
&-container.transform-origin-bottom-right {
transform-origin: bottom right;
}
&-container.transform-origin-left {
transform-origin: left;
}
&-container.transform-origin-right {
transform-origin: right;
}
&-container.transform-origin-top {
transform-origin: top;
}
&-container.transform-origin-bottom {
transform-origin: bottom;
}
&-container.transform-origin-center {
transform-origin: center;
}
&::before {
background-color: black;
bottom: 0;
content: "";
left: 0;
opacity: 0;
pointer-events: none;
position: fixed;
right: 0;
top: 0;
transition: opacity 0.5s 0s ease-in-out;
z-index: -1; z-index: -1;
} }
.wall { @media (min-width: 576px) {
.wall-item { &:hover {
line-height: 0; z-index: 2;
overflow: visible;
padding: 0;
position: relative;
width: 20%;
@media (max-width: 576px) { .wall-item-media {
width: 100%; transition-delay: 0.5s;
transition-duration: 0.5s;
z-index: 10;
}
&::before {
opacity: 0.8;
transition-delay: 0.5s;
}
.wall-item-container {
background-color: black;
position: relative;
transform: scale(2);
transition-delay: 0.5s;
z-index: 10;
}
}
} }
} }
} }