diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql index 48a9a437c..e050bfdd1 100644 --- a/graphql/documents/data/config.graphql +++ b/graphql/documents/data/config.graphql @@ -21,6 +21,7 @@ fragment ConfigGeneralData on ConfigGeneralResult { fragment ConfigInterfaceData on ConfigInterfaceResult { soundOnPreview wallShowTitle + wallPlayback maximumLoopDuration autostartVideo showStudioAsText diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index b98d04aa1..0f65c97fb 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -86,6 +86,8 @@ input ConfigInterfaceInput { soundOnPreview: Boolean """Show title and tags in wall view""" wallShowTitle: Boolean + """Wall playback type""" + wallPlayback: String """Maximum duration (in seconds) in which a scene video will loop in the scene player""" maximumLoopDuration: Int """If true, video will autostart on load in the scene player""" @@ -103,6 +105,8 @@ type ConfigInterfaceResult { soundOnPreview: Boolean """Show title and tags in wall view""" wallShowTitle: Boolean + """Wall playback type""" + wallPlayback: String """Maximum duration (in seconds) in which a scene video will loop in the scene player""" maximumLoopDuration: Int """If true, video will autostart on load in the scene player""" diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index c069631ce..dede131fc 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -1,6 +1,8 @@ input GenerateMetadataInput { sprites: Boolean! previews: Boolean! + previewPreset: PreviewPreset + imagePreviews: Boolean! markers: Boolean! transcodes: Boolean! """gallery thumbnails for cache usage""" @@ -22,6 +24,16 @@ input AutoTagMetadataInput { type MetadataUpdateStatus { progress: Float! - status: String! + status: 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 +} diff --git a/pkg/api/resolver_mutation_configure.go b/pkg/api/resolver_mutation_configure.go index 3e75d245e..ab7deb743 100644 --- a/pkg/api/resolver_mutation_configure.go +++ b/pkg/api/resolver_mutation_configure.go @@ -111,6 +111,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models. config.Set(config.WallShowTitle, *input.WallShowTitle) } + if input.WallPlayback != nil { + config.Set(config.WallPlayback, *input.WallPlayback) + } + if input.MaximumLoopDuration != nil { config.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration) } diff --git a/pkg/api/resolver_mutation_metadata.go b/pkg/api/resolver_mutation_metadata.go index 25ee96287..65a743af8 100644 --- a/pkg/api/resolver_mutation_metadata.go +++ b/pkg/api/resolver_mutation_metadata.go @@ -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) { - 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 } diff --git a/pkg/api/resolver_query_configuration.go b/pkg/api/resolver_query_configuration.go index 89913a978..da059de60 100644 --- a/pkg/api/resolver_query_configuration.go +++ b/pkg/api/resolver_query_configuration.go @@ -65,6 +65,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult { func makeConfigInterfaceResult() *models.ConfigInterfaceResult { soundOnPreview := config.GetSoundOnPreview() wallShowTitle := config.GetWallShowTitle() + wallPlayback := config.GetWallPlayback() maximumLoopDuration := config.GetMaximumLoopDuration() autostartVideo := config.GetAutostartVideo() showStudioAsText := config.GetShowStudioAsText() @@ -75,6 +76,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult { return &models.ConfigInterfaceResult{ SoundOnPreview: &soundOnPreview, WallShowTitle: &wallShowTitle, + WallPlayback: &wallPlayback, MaximumLoopDuration: &maximumLoopDuration, AutostartVideo: &autostartVideo, ShowStudioAsText: &showStudioAsText, diff --git a/pkg/ffmpeg/encoder_scene_preview_chunk.go b/pkg/ffmpeg/encoder_scene_preview_chunk.go index 00fa8c6e2..4f6a8a6fd 100644 --- a/pkg/ffmpeg/encoder_scene_preview_chunk.go +++ b/pkg/ffmpeg/encoder_scene_preview_chunk.go @@ -13,9 +13,10 @@ type ScenePreviewChunkOptions struct { OutputPath string } -func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePreviewChunkOptions) { +func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePreviewChunkOptions, preset string) { args := []string{ "-v", "error", + "-xerror", "-ss", strconv.Itoa(options.Time), "-i", probeResult.Path, "-t", "0.75", @@ -25,7 +26,7 @@ func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePre "-pix_fmt", "yuv420p", "-profile:v", "high", "-level", "4.2", - "-preset", "slow", + "-preset", preset, "-crf", "21", "-threads", "4", "-vf", fmt.Sprintf("scale=%v:-2", options.Width), diff --git a/pkg/ffmpeg/ffprobe.go b/pkg/ffmpeg/ffprobe.go index 206fd210f..e309619e3 100644 --- a/pkg/ffmpeg/ffprobe.go +++ b/pkg/ffmpeg/ffprobe.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/manager/config" ) @@ -264,7 +265,11 @@ func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) { result.Container = probeJSON.Format.FormatName duration, _ := strconv.ParseFloat(probeJSON.Format.Duration, 64) 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.StartTime, _ = strconv.ParseFloat(probeJSON.Format.StartTime, 64) result.CreationTime = probeJSON.Format.Tags.CreationTime.Time diff --git a/pkg/manager/config/config.go b/pkg/manager/config/config.go index 3ab9782fc..f21493329 100644 --- a/pkg/manager/config/config.go +++ b/pkg/manager/config/config.go @@ -54,6 +54,7 @@ const MaximumLoopDuration = "maximum_loop_duration" const AutostartVideo = "autostart_video" const ShowStudioAsText = "show_studio_as_text" const CSSEnabled = "cssEnabled" +const WallPlayback = "wall_playback" // Playback force codec,container const ForceMKV = "forceMKV" @@ -241,6 +242,11 @@ func GetWallShowTitle() bool { return viper.GetBool(WallShowTitle) } +func GetWallPlayback() string { + viper.SetDefault(WallPlayback, "video") + return viper.GetString(WallPlayback) +} + func GetMaximumLoopDuration() int { viper.SetDefault(MaximumLoopDuration, 0) return viper.GetInt(MaximumLoopDuration) diff --git a/pkg/manager/generator_preview.go b/pkg/manager/generator_preview.go index 5043ce349..f91fdf956 100644 --- a/pkg/manager/generator_preview.go +++ b/pkg/manager/generator_preview.go @@ -16,9 +16,14 @@ type PreviewGenerator struct { VideoFilename string ImageFilename 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) if !exists { return nil, err @@ -37,6 +42,9 @@ func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoFilename string, image VideoFilename: videoFilename, ImageFilename: imageFilename, OutputDirectory: outputDirectory, + GenerateVideo: generateVideo, + GenerateImage: generateImage, + PreviewPreset: previewPreset, }, nil } @@ -47,11 +55,16 @@ func (g *PreviewGenerator) Generate() error { if err := g.generateConcatFile(); err != nil { return err } - if err := g.generateVideo(&encoder); err != nil { - return err + + if g.GenerateVideo { + if err := g.generateVideo(&encoder); err != nil { + return err + } } - if err := g.generateImage(&encoder); err != nil { - return err + if g.GenerateImage { + if err := g.generateImage(&encoder); err != nil { + return err + } } return nil } @@ -91,7 +104,7 @@ func (g *PreviewGenerator) generateVideo(encoder *ffmpeg.Encoder) error { Width: 640, OutputPath: chunkOutputPath, } - encoder.ScenePreviewVideoChunk(g.Info.VideoFile, options) + encoder.ScenePreviewVideoChunk(g.Info.VideoFile, options, g.PreviewPreset) } videoOutputPath := filepath.Join(g.OutputDirectory, g.VideoFilename) diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index fe041c8bf..70d83c09a 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -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 { return } @@ -183,6 +183,11 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod //this.job.total = await ObjectionUtils.getCount(Scene); instance.Paths.Generated.EnsureTmpDir() + preset := string(models.PreviewPresetSlow) + if previewPreset != nil && previewPreset.IsValid() { + preset = string(*previewPreset) + } + go func() { 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") return } - totalsNeeded := s.neededGenerate(scenes, sprites, previews, markers, transcodes) + totalsNeeded := s.neededGenerate(scenes, sprites, previews, imagePreviews, markers, transcodes) if totalsNeeded == nil { logger.Infof("Taking too long to count content. Skipping...") logger.Infof("Generating content") } 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 { s.Status.setProgress(i, total) @@ -244,7 +249,7 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod } if previews { - task := GeneratePreviewTask{Scene: *scene} + task := GeneratePreviewTask{Scene: *scene, ImagePreview: imagePreviews, PreviewPreset: preset} go task.Start(&wg) } @@ -602,13 +607,14 @@ func (s *singleton) neededScan(paths []string) int64 { } type totalsGenerate struct { - sprites int64 - previews int64 - markers int64 - transcodes int64 + sprites int64 + previews int64 + imagePreviews int64 + markers 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 const timeout = 90 * time.Second @@ -633,10 +639,13 @@ func (s *singleton) neededGenerate(scenes []*models.Scene, sprites, previews, ma } if previews { - task := GeneratePreviewTask{Scene: *scene} - if !task.doesPreviewExist(task.Scene.Checksum) { + task := GeneratePreviewTask{Scene: *scene, ImagePreview: imagePreviews} + if !task.doesVideoPreviewExist(task.Scene.Checksum) { totals.previews++ } + if imagePreviews && !task.doesImagePreviewExist(task.Scene.Checksum) { + totals.imagePreviews++ + } } if markers { diff --git a/pkg/manager/task_generate_preview.go b/pkg/manager/task_generate_preview.go index 380560ba0..4aba82095 100644 --- a/pkg/manager/task_generate_preview.go +++ b/pkg/manager/task_generate_preview.go @@ -9,7 +9,9 @@ import ( ) type GeneratePreviewTask struct { - Scene models.Scene + Scene models.Scene + ImagePreview bool + PreviewPreset string } func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) { @@ -17,7 +19,8 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) { videoFilename := t.videoFilename() 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 } @@ -27,7 +30,7 @@ func (t *GeneratePreviewTask) Start(wg *sync.WaitGroup) { 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 { logger.Errorf("error creating preview generator: %s", err.Error()) 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)) + return videoExists +} + +func (t *GeneratePreviewTask) doesImagePreviewExist(sceneChecksum string) bool { imageExists, _ := utils.FileExists(instance.Paths.Scene.GetStreamPreviewImagePath(sceneChecksum)) - return videoExists && imageExists + return imageExists } func (t *GeneratePreviewTask) videoFilename() string { diff --git a/pkg/utils/file.go b/pkg/utils/file.go index 1e2ad1aed..b80e888c9 100644 --- a/pkg/utils/file.go +++ b/pkg/utils/file.go @@ -28,11 +28,8 @@ func FileExists(path string) (bool, error) { _, err := os.Stat(path) if err == 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 diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel.tsx index 3b36c6abd..0cda2d249 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel.tsx @@ -9,6 +9,7 @@ export const SettingsInterfacePanel: React.FC = () => { const { data: config, error, loading } = useConfiguration(); const [soundOnPreview, setSoundOnPreview] = useState(true); const [wallShowTitle, setWallShowTitle] = useState(true); + const [wallPlayback, setWallPlayback] = useState("video"); const [maximumLoopDuration, setMaximumLoopDuration] = useState(0); const [autostartVideo, setAutostartVideo] = useState(false); const [showStudioAsText, setShowStudioAsText] = useState(false); @@ -19,6 +20,7 @@ export const SettingsInterfacePanel: React.FC = () => { const [updateInterfaceConfig] = useConfigureInterface({ soundOnPreview, wallShowTitle, + wallPlayback, maximumLoopDuration, autostartVideo, showStudioAsText, @@ -31,6 +33,7 @@ export const SettingsInterfacePanel: React.FC = () => { const iCfg = config?.configuration?.interface; setSoundOnPreview(iCfg?.soundOnPreview ?? true); setWallShowTitle(iCfg?.wallShowTitle ?? true); + setWallPlayback(iCfg?.wallPlayback ?? "video"); setMaximumLoopDuration(iCfg?.maximumLoopDuration ?? 0); setAutostartVideo(iCfg?.autostartVideo ?? false); setShowStudioAsText(iCfg?.showStudioAsText ?? false); @@ -85,6 +88,22 @@ export const SettingsInterfacePanel: React.FC = () => { label="Enable sound" onChange={() => setSoundOnPreview(!soundOnPreview)} /> + +
Preview Type
+
+ ) => + setWallPlayback(e.currentTarget.value) + } + > + + + + Configuration for wall items diff --git a/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx b/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx index 391e1fde0..22e5c8c66 100644 --- a/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx +++ b/ui/v2.5/src/components/Settings/SettingsTasksPanel/GenerateButton.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { Button, Form } from "react-bootstrap"; import { mutateMetadataGenerate } from "src/core/StashService"; +import { PreviewPreset } from "src/core/generated-graphql"; import { useToast } from "src/hooks"; export const GenerateButton: React.FC = () => { @@ -10,15 +11,21 @@ export const GenerateButton: React.FC = () => { const [markers, setMarkers] = useState(true); const [transcodes, setTranscodes] = useState(false); const [thumbnails, setThumbnails] = useState(false); + const [imagePreviews, setImagePreviews] = useState(false); + const [previewPreset, setPreviewPreset] = useState( + PreviewPreset.Slow + ); async function onGenerate() { try { await mutateMetadataGenerate({ sprites, previews, + imagePreviews: previews && imagePreviews, markers, transcodes, thumbnails, + previewPreset: (previewPreset as PreviewPreset) ?? undefined, }); Toast.success({ content: "Started generating" }); } catch (e) { @@ -29,18 +36,54 @@ export const GenerateButton: React.FC = () => { return ( <> - setSprites(!sprites)} - /> setPreviews(!previews)} /> +
+
+ setImagePreviews(!imagePreviews)} + className="ml-2 flex-grow" + /> +
+ + +
Preview encoding preset
+
+ ) => + setPreviewPreset(e.currentTarget.value) + } + disabled={!previews} + className="col-1" + > + {Object.keys(PreviewPreset).map((p) => ( + + ))} + + + The preset regulates size, quality and encoding time of preview + generation. Presets beyond “slow” have diminishing returns and are + not recommended. + +
+ setSprites(!sprites)} + /> void; clickHandler?: ( item: GQL.SlimSceneDataFragment | GQL.SceneMarkerDataFragment ) => 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; + 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
; + + if (isMissing) { + return ( +
+ Pending preview generation +
+ ); + } + + const image = ( + + ); + const video = ( +