Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generate screenshot images for markers #1604

Merged
merged 5 commits into from
Sep 15, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions graphql/documents/data/scene-marker.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ fragment SceneMarkerData on SceneMarker {
seconds
stream
preview
screenshot

scene {
id
Expand Down
2 changes: 2 additions & 0 deletions graphql/schema/types/metadata.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ input GenerateMetadataInput {
imagePreviews: Boolean!
previewOptions: GeneratePreviewOptionsInput
markers: Boolean!
markerImagePreviews: Boolean!
markerScreenshots: Boolean!
transcodes: Boolean!
phashes: Boolean!

Expand Down
2 changes: 2 additions & 0 deletions graphql/schema/types/scene-marker.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ type SceneMarker {
stream: String! # Resolver
"""The path to the preview image for this marker"""
preview: String! # Resolver
"""The path to the screenshot image for this marker"""
screenshot: String! # Resolver
}

input SceneMarkerCreateInput {
Expand Down
6 changes: 6 additions & 0 deletions pkg/api/resolver_model_scene_marker.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ func (r *sceneMarkerResolver) Preview(ctx context.Context, obj *models.SceneMark
return urlbuilders.NewSceneURLBuilder(baseURL, sceneID).GetSceneMarkerStreamPreviewURL(obj.ID), nil
}

func (r *sceneMarkerResolver) Screenshot(ctx context.Context, obj *models.SceneMarker) (string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
sceneID := int(obj.SceneID.Int64)
return urlbuilders.NewSceneURLBuilder(baseURL, sceneID).GetSceneMarkerStreamScreenshotURL(obj.ID), nil
}

func (r *sceneMarkerResolver) CreatedAt(ctx context.Context, obj *models.SceneMarker) (*time.Time, error) {
return &obj.CreatedAt.Timestamp, nil
}
Expand Down
28 changes: 28 additions & 0 deletions pkg/api/routes_scene.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ func (rs sceneRoutes) Routes() chi.Router {

r.Get("/scene_marker/{sceneMarkerId}/stream", rs.SceneMarkerStream)
r.Get("/scene_marker/{sceneMarkerId}/preview", rs.SceneMarkerPreview)
r.Get("/scene_marker/{sceneMarkerId}/screenshot", rs.SceneMarkerScreenshot)
})
r.With(SceneCtx).Get("/{sceneId}_thumbs.vtt", rs.VttThumbs)
r.With(SceneCtx).Get("/{sceneId}_sprite.jpg", rs.VttSprite)
Expand Down Expand Up @@ -319,6 +320,33 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request)
http.ServeFile(w, r, filepath)
}

func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
sceneMarkerID, _ := strconv.Atoi(chi.URLParam(r, "sceneMarkerId"))
var sceneMarker *models.SceneMarker
if err := rs.txnManager.WithReadTxn(r.Context(), func(repo models.ReaderRepository) error {
var err error
sceneMarker, err = repo.SceneMarker().Find(sceneMarkerID)
return err
}); err != nil {
logger.Warnf("Error when getting scene marker for stream: %s", err.Error())
http.Error(w, http.StatusText(500), 500)
return
}
filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))

// If the image doesn't exist, send the placeholder
exists, _ := utils.FileExists(filepath)
if !exists {
w.Header().Set("Content-Type", "image/png")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write(utils.PendingGenerateResource)
return
}

http.ServeFile(w, r, filepath)
}

// endregion

func SceneCtx(next http.Handler) http.Handler {
Expand Down
4 changes: 4 additions & 0 deletions pkg/api/urlbuilders/scene.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ func (b SceneURLBuilder) GetSceneMarkerStreamPreviewURL(sceneMarkerID int) strin
return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + strconv.Itoa(sceneMarkerID) + "/preview"
}

func (b SceneURLBuilder) GetSceneMarkerStreamScreenshotURL(sceneMarkerID int) string {
return b.BaseURL + "/scene/" + b.SceneID + "/scene_marker/" + strconv.Itoa(sceneMarkerID) + "/screenshot"
}

func (b SceneURLBuilder) GetFunscriptURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/funscript"
}
2 changes: 2 additions & 0 deletions pkg/manager/manager_tasks.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,8 @@ func (s *singleton) Generate(ctx context.Context, input models.GenerateMetadataI
Scene: scene,
Overwrite: overwrite,
fileNamingAlgorithm: fileNamingAlgo,
ImagePreview: input.MarkerImagePreviews,
Screenshot: input.MarkerScreenshots,
}
go progress.ExecuteTask(fmt.Sprintf("Generating markers for %s", scene.Path), func() {
task.Start(&wg)
Expand Down
4 changes: 4 additions & 0 deletions pkg/manager/paths/paths_scene_markers.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,7 @@ func (sp *sceneMarkerPaths) GetStreamPath(checksum string, seconds int) string {
func (sp *sceneMarkerPaths) GetStreamPreviewImagePath(checksum string, seconds int) string {
return filepath.Join(sp.generated.Markers, checksum, strconv.Itoa(seconds)+".webp")
}

func (sp *sceneMarkerPaths) GetStreamScreenshotPath(checksum string, seconds int) string {
return filepath.Join(sp.generated.Markers, checksum, strconv.Itoa(seconds)+".jpg")
}
11 changes: 10 additions & 1 deletion pkg/manager/scene.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ func DeleteGeneratedSceneFiles(scene *models.Scene, fileNamingAlgo models.HashAl
func DeleteSceneMarkerFiles(scene *models.Scene, seconds int, fileNamingAlgo models.HashAlgorithm) {
videoPath := GetInstance().Paths.SceneMarkers.GetStreamPath(scene.GetHash(fileNamingAlgo), seconds)
imagePath := GetInstance().Paths.SceneMarkers.GetStreamPreviewImagePath(scene.GetHash(fileNamingAlgo), seconds)
screenshotPath := GetInstance().Paths.SceneMarkers.GetStreamScreenshotPath(scene.GetHash(fileNamingAlgo), seconds)

exists, _ := utils.FileExists(videoPath)
if exists {
Expand All @@ -161,7 +162,15 @@ func DeleteSceneMarkerFiles(scene *models.Scene, seconds int, fileNamingAlgo mod
if exists {
err := os.Remove(imagePath)
if err != nil {
logger.Warnf("Could not delete file %s: %s", videoPath, err.Error())
logger.Warnf("Could not delete file %s: %s", imagePath, err.Error())
}
}

exists, _ = utils.FileExists(screenshotPath)
if exists {
err := os.Remove(screenshotPath)
if err != nil {
logger.Warnf("Could not delete file %s: %s", screenshotPath, err.Error())
}
}
}
Expand Down
46 changes: 39 additions & 7 deletions pkg/manager/task_generate_markers.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ type GenerateMarkersTask struct {
Marker *models.SceneMarker
Overwrite bool
fileNamingAlgorithm models.HashAlgorithm

ImagePreview bool
Screenshot bool
}

func (t *GenerateMarkersTask) Start(wg *sizedwaitgroup.SizedWaitGroup) {
Expand Down Expand Up @@ -94,7 +97,8 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *ffmpeg.VideoFile, scene
seconds := int(sceneMarker.Seconds)

videoExists := t.videoExists(sceneHash, seconds)
imageExists := t.imageExists(sceneHash, seconds)
imageExists := !t.ImagePreview || t.imageExists(sceneHash, seconds)
screenshotExists := !t.Screenshot || t.screenshotExists(sceneHash, seconds)

baseFilename := strconv.Itoa(seconds)

Expand All @@ -119,7 +123,7 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *ffmpeg.VideoFile, scene
}
}

if t.Overwrite || !imageExists {
if t.ImagePreview && (t.Overwrite || !imageExists) {
imageFilename := baseFilename + ".webp"
imagePath := instance.Paths.SceneMarkers.GetStreamPreviewImagePath(sceneHash, seconds)

Expand All @@ -131,6 +135,24 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *ffmpeg.VideoFile, scene
logger.Debug("created marker image: ", imagePath)
}
}

if t.Screenshot && (t.Overwrite || !screenshotExists) {
screenshotFilename := baseFilename + ".jpg"
screenshotPath := instance.Paths.SceneMarkers.GetStreamScreenshotPath(sceneHash, seconds)

screenshotOptions := ffmpeg.ScreenshotOptions{
OutputPath: instance.Paths.Generated.GetTmpPath(screenshotFilename), // tmp output in case the process ends abruptly
Quality: 2,
Width: videoFile.Width,
Time: float64(seconds),
}
if err := encoder.Screenshot(*videoFile, screenshotOptions); err != nil {
logger.Errorf("[generator] failed to generate marker screenshot: %s", err)
} else {
_ = utils.SafeMove(screenshotOptions.OutputPath, screenshotPath)
logger.Debug("created marker screenshot: ", screenshotPath)
}
}
}

func (t *GenerateMarkersTask) isMarkerNeeded() int {
Expand Down Expand Up @@ -166,12 +188,11 @@ func (t *GenerateMarkersTask) markerExists(sceneChecksum string, seconds int) bo
return false
}

videoPath := instance.Paths.SceneMarkers.GetStreamPath(sceneChecksum, seconds)
imagePath := instance.Paths.SceneMarkers.GetStreamPreviewImagePath(sceneChecksum, seconds)
videoExists, _ := utils.FileExists(videoPath)
imageExists, _ := utils.FileExists(imagePath)
videoExists := t.videoExists(sceneChecksum, seconds)
imageExists := !t.ImagePreview || t.imageExists(sceneChecksum, seconds)
screenshotExists := !t.Screenshot || t.screenshotExists(sceneChecksum, seconds)

return videoExists && imageExists
return videoExists && imageExists && screenshotExists
}

func (t *GenerateMarkersTask) videoExists(sceneChecksum string, seconds int) bool {
Expand All @@ -195,3 +216,14 @@ func (t *GenerateMarkersTask) imageExists(sceneChecksum string, seconds int) boo

return imageExists
}

func (t *GenerateMarkersTask) screenshotExists(sceneChecksum string, seconds int) bool {
if sceneChecksum == "" {
return false
}

screenshotPath := instance.Paths.SceneMarkers.GetStreamScreenshotPath(sceneChecksum, seconds)
screenshotExists, _ := utils.FileExists(screenshotPath)

return screenshotExists
}
1 change: 1 addition & 0 deletions ui/v2.5/src/components/Changelog/versions/v0100.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
### ✨ New Features
* Added options to generate webp and static preview files for markers. ([#1604](https://github.com/stashapp/stash/pull/1604))
* Added sort by option for gallery rating. ([#1720](https://github.com/stashapp/stash/pull/1720))
* Added support for querying scene scrapers using keywords. ([#1712](https://github.com/stashapp/stash/pull/1712))
* Added support for Studio aliases. ([#1660](https://github.com/stashapp/stash/pull/1660))
Expand Down
29 changes: 29 additions & 0 deletions ui/v2.5/src/components/Scenes/SceneGenerateDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
const [previewPreset, setPreviewPreset] = useState<string>(
GQL.PreviewPreset.Slow
);
const [markerImagePreviews, setMarkerImagePreviews] = useState(false);
const [markerScreenshots, setMarkerScreenshots] = useState(false);

const [previewOptionsOpen, setPreviewOptionsOpen] = useState(false);

Expand Down Expand Up @@ -67,6 +69,8 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
previews,
imagePreviews: previews && imagePreviews,
markers,
markerImagePreviews: markers && markerImagePreviews,
markerScreenshots: markers && markerScreenshots,
transcodes,
overwrite,
sceneIDs: props.selectedIds,
Expand Down Expand Up @@ -276,6 +280,31 @@ export const SceneGenerateDialog: React.FC<ISceneGenerateDialogProps> = (
label={intl.formatMessage({ id: "dialogs.scene_gen.markers" })}
onChange={() => setMarkers(!markers)}
/>
<div className="d-flex flex-row">
<div>↳</div>
<Form.Group>
<Form.Check
id="marker-image-preview-task"
checked={markerImagePreviews}
disabled={!markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_image_previews",
})}
onChange={() => setMarkerImagePreviews(!markerImagePreviews)}
className="ml-2 flex-grow"
/>
<Form.Check
id="marker-screenshot-task"
checked={markerScreenshots}
disabled={!markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_screenshots",
})}
onChange={() => setMarkerScreenshots(!markerScreenshots)}
className="ml-2 flex-grow"
/>
</Form.Group>
</div>
<Form.Check
id="transcode-task"
checked={transcodes}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export const GenerateButton: React.FC = () => {
const [markers, setMarkers] = useState(true);
const [transcodes, setTranscodes] = useState(false);
const [imagePreviews, setImagePreviews] = useState(false);
const [markerImagePreviews, setMarkerImagePreviews] = useState(false);
const [markerScreenshots, setMarkerScreenshots] = useState(false);

async function onGenerate() {
try {
Expand All @@ -22,6 +24,8 @@ export const GenerateButton: React.FC = () => {
previews,
imagePreviews: previews && imagePreviews,
markers,
markerImagePreviews: markers && markerImagePreviews,
markerScreenshots: markers && markerScreenshots,
transcodes,
});
Toast.success({
Expand Down Expand Up @@ -68,6 +72,31 @@ export const GenerateButton: React.FC = () => {
label={intl.formatMessage({ id: "dialogs.scene_gen.markers" })}
onChange={() => setMarkers(!markers)}
/>
<div className="d-flex flex-row">
<div>↳</div>
<Form.Group>
<Form.Check
id="marker-image-preview-task"
checked={markerImagePreviews}
disabled={!markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_image_previews",
})}
onChange={() => setMarkerImagePreviews(!markerImagePreviews)}
className="ml-2 flex-grow"
/>
<Form.Check
id="marker-screenshot-task"
checked={markerScreenshots}
disabled={!markers}
label={intl.formatMessage({
id: "dialogs.scene_gen.marker_screenshots",
})}
onChange={() => setMarkerScreenshots(!markerScreenshots)}
className="ml-2 flex-grow"
/>
</Form.Group>
</div>
<Form.Check
id="transcode-task"
checked={transcodes}
Expand Down
1 change: 1 addition & 0 deletions ui/v2.5/src/components/Wall/WallItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ export const WallItem: React.FC<IWallItemProps> = (props: IWallItemProps) => {
? {
video: props.sceneMarker.stream,
animation: props.sceneMarker.preview,
image: props.sceneMarker.screenshot,
}
: props.scene
? {
Expand Down
2 changes: 2 additions & 0 deletions ui/v2.5/src/locales/en-GB.json
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,8 @@
"scene_gen": {
"image_previews": "Image Previews (animated WebP previews, only required if Preview Type is set to Animated Image)",
"markers": "Markers (20 second videos which begin at the given timecode)",
"marker_image_previews": "Marker Previews (animated WebP previews, only required if Preview Type is set to Animated Image)",
"marker_screenshots": "Marker Screenshots (static JPG image, only required if Preview Type is set to Static Image)",
"overwrite": "Overwrite existing generated files",
"phash": "Perceptual hashes (for deduplication)",
"preview_exclude_end_time_desc": "Exclude the last x seconds from scene previews. This can be a value in seconds, or a percentage (eg 2%) of the total scene duration.",
Expand Down