This commit is contained in:
Stash-KennyG 2026-05-05 08:03:26 -05:00 committed by GitHub
commit 9408f2f3c3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 635 additions and 126 deletions

View file

@ -62,6 +62,18 @@ type Scene {
play_duration: Float
"The number ot times a scene has been played"
play_count: Int
"""
Describes where the current cover image came from.
NULL | unknown/no-cover/legacy.
default | "Generate default thumbnail"
clipboard | "From clipboard"
userscript | set via GraphQL outside UI
url:* | "From URL..."
stash:* | StashBox imported image
timestamp:* | "Generate thumbnail from Current"
file:* | "From file..."
"""
cover_image_source: String
"Times a scene was played"
play_history: [Time!]!
@ -114,6 +126,18 @@ input SceneCreateInput {
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
cover_image: String
"""
Describes where the current cover image came from.
NULL | unknown/no-cover/legacy.
default | "Generate default thumbnail"
clipboard | "From clipboard"
userscript | set via GraphQL outside UI
url:* | "From URL..."
stash:* | StashBox imported image
timestamp:* | "Generate thumbnail from Current"
file:* | "From file..."
"""
cover_image_source: String
stash_ids: [StashIDInput!]
"""
@ -149,6 +173,18 @@ input SceneUpdateInput {
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
cover_image: String
"""
Describes where the current cover image came from.
NULL | unknown/no-cover/legacy.
default | "Generate default thumbnail"
clipboard | "From clipboard"
userscript | set via GraphQL outside UI
url:* | "From URL..."
stash:* | StashBox imported image
timestamp:* | "Generate thumbnail from Current"
file:* | "From file..."
"""
cover_image_source: String
stash_ids: [StashIDInput!]
"The time index a scene was left at"

View file

@ -20,6 +20,24 @@ import (
"github.com/stashapp/stash/pkg/utils"
)
// defaults to 'userscript when image is provided and source is not provided
// returns nil if source null and image is not provided
// otherwise returns the source trimmed
func applyDefaultCoverSource(
coverImageSource *string,
coverImageData []byte,
) *string {
if len(coverImageData) > 0 && coverImageSource == nil {
defaultSource := "userscript"
return &defaultSource
}
if coverImageSource == nil {
return nil
}
trimmed := strings.TrimSpace(*coverImageSource)
return &trimmed
}
// used to refetch scene after hooks run
func (r *mutationResolver) getScene(ctx context.Context, id int) (ret *models.Scene, err error) {
if err := r.withTxn(ctx, func(ctx context.Context) error {
@ -103,6 +121,11 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr
}
}
newScene.CoverImageSource = applyDefaultCoverSource(
input.CoverImageSource,
coverImageData,
)
customFields := convertMapJSONNumbers(input.CustomFields)
if err := r.withTxn(ctx, func(ctx context.Context) error {
@ -187,6 +210,7 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr
updatedScene.Code = translator.optionalString(input.Code, "code")
updatedScene.Details = translator.optionalString(input.Details, "details")
updatedScene.Director = translator.optionalString(input.Director, "director")
updatedScene.CoverImageSource = translator.optionalString(input.CoverImageSource, "cover_image_source")
updatedScene.Rating = translator.optionalInt(input.Rating100, "rating100")
if input.OCounter != nil {
@ -311,6 +335,10 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
if err != nil {
return nil, fmt.Errorf("processing cover image: %w", err)
}
if len(coverImageData) > 0 && !updatedScene.CoverImageSource.Set {
updatedScene.CoverImageSource = models.NewOptionalString("userscript")
}
}
var customFields *models.CustomFieldsInput
@ -627,6 +655,9 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
if err != nil {
return nil, fmt.Errorf("processing cover image: %w", err)
}
if len(coverImageData) > 0 && !values.CoverImageSource.Set {
values.CoverImageSource = models.NewOptionalString("userscript")
}
}
if input.Values.CustomFields != nil {

View file

@ -10,12 +10,13 @@ import (
// Scene stores the metadata for a single video scene.
type Scene struct {
ID int `json:"id"`
Title string `json:"title"`
Code string `json:"code"`
Details string `json:"details"`
Director string `json:"director"`
Date *Date `json:"date"`
ID int `json:"id"`
Title string `json:"title"`
Code string `json:"code"`
Details string `json:"details"`
Director string `json:"director"`
CoverImageSource *string `json:"cover_image_source"`
Date *Date `json:"date"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"`
Organized bool `json:"organized"`
@ -70,11 +71,12 @@ type UpdateSceneInput struct {
// ScenePartial represents part of a Scene object. It is used to update
// the database entry.
type ScenePartial struct {
Title OptionalString
Code OptionalString
Details OptionalString
Director OptionalString
Date OptionalDate
Title OptionalString
Code OptionalString
Details OptionalString
Director OptionalString
CoverImageSource OptionalString
Date OptionalDate
// Rating expressed in 1-100 scale
Rating OptionalInt
Organized OptionalBool
@ -212,21 +214,22 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput {
}
ret := SceneUpdateInput{
ID: strconv.Itoa(id),
Title: s.Title.Ptr(),
Code: s.Code.Ptr(),
Details: s.Details.Ptr(),
Director: s.Director.Ptr(),
Urls: s.URLs.Strings(),
Date: dateStr,
Rating100: s.Rating.Ptr(),
Organized: s.Organized.Ptr(),
StudioID: s.StudioID.StringPtr(),
GalleryIds: s.GalleryIDs.IDStrings(),
PerformerIds: s.PerformerIDs.IDStrings(),
Movies: s.GroupIDs.SceneMovieInputs(),
TagIds: s.TagIDs.IDStrings(),
StashIds: stashIDs.ToStashIDInputs(),
ID: strconv.Itoa(id),
Title: s.Title.Ptr(),
Code: s.Code.Ptr(),
Details: s.Details.Ptr(),
Director: s.Director.Ptr(),
CoverImageSource: s.CoverImageSource.Ptr(),
Urls: s.URLs.Strings(),
Date: dateStr,
Rating100: s.Rating.Ptr(),
Organized: s.Organized.Ptr(),
StudioID: s.StudioID.StringPtr(),
GalleryIds: s.GalleryIDs.IDStrings(),
PerformerIds: s.PerformerIDs.IDStrings(),
Movies: s.GroupIDs.SceneMovieInputs(),
TagIds: s.TagIDs.IDStrings(),
StashIds: stashIDs.ToStashIDInputs(),
}
return ret

View file

@ -190,8 +190,9 @@ type SceneCreateInput struct {
Groups []SceneGroupInput `json:"groups"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
CoverImage *string `json:"cover_image"`
StashIds []StashIDInput `json:"stash_ids"`
CoverImage *string `json:"cover_image"`
CoverImageSource *string `json:"cover_image_source"`
StashIds []StashIDInput `json:"stash_ids"`
// The first id will be assigned as primary.
// Files will be reassigned from existing scenes if applicable.
// Files must not already be primary for another scene.
@ -219,13 +220,14 @@ type SceneUpdateInput struct {
Groups []SceneGroupInput `json:"groups"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
CoverImage *string `json:"cover_image"`
StashIds []StashIDInput `json:"stash_ids"`
ResumeTime *float64 `json:"resume_time"`
PlayDuration *float64 `json:"play_duration"`
PlayCount *int `json:"play_count"`
PrimaryFileID *string `json:"primary_file_id"`
CustomFields *CustomFieldsInput
CoverImage *string `json:"cover_image"`
CoverImageSource *string `json:"cover_image_source"`
StashIds []StashIDInput `json:"stash_ids"`
ResumeTime *float64 `json:"resume_time"`
PlayDuration *float64 `json:"play_duration"`
PlayCount *int `json:"play_count"`
PrimaryFileID *string `json:"primary_file_id"`
CustomFields *CustomFieldsInput
}
type SceneDestroyInput struct {

View file

@ -34,7 +34,7 @@ const (
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
)
var appSchemaVersion uint = 85
var appSchemaVersion uint = 86
//go:embed migrations/*.sql
var migrationsBox embed.FS

View file

@ -0,0 +1,10 @@
ALTER TABLE `scenes` ADD COLUMN `cover_image_source` text CHECK (
`cover_image_source` IS NULL
OR `cover_image_source` = 'default'
OR `cover_image_source` = 'clipboard'
OR `cover_image_source` = 'userscript'
OR `cover_image_source` LIKE 'url:%'
OR `cover_image_source` LIKE 'stash:%'
OR `cover_image_source` LIKE 'timestamp:%'
OR `cover_image_source` LIKE 'file:%'
);

View file

@ -77,13 +77,14 @@ ORDER BY files.size DESC;
`
type sceneRow struct {
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
Code zero.String `db:"code"`
Details zero.String `db:"details"`
Director zero.String `db:"director"`
Date NullDate `db:"date"`
DatePrecision null.Int `db:"date_precision"`
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
Code zero.String `db:"code"`
CoverImageSource zero.String `db:"cover_image_source"`
Details zero.String `db:"details"`
Director zero.String `db:"director"`
Date NullDate `db:"date"`
DatePrecision null.Int `db:"date_precision"`
// expressed as 1-100
Rating null.Int `db:"rating"`
Organized bool `db:"organized"`
@ -103,6 +104,7 @@ func (r *sceneRow) fromScene(o models.Scene) {
r.Code = zero.StringFrom(o.Code)
r.Details = zero.StringFrom(o.Details)
r.Director = zero.StringFrom(o.Director)
r.CoverImageSource = zero.StringFromPtr(o.CoverImageSource)
r.Date = NullDateFromDatePtr(o.Date)
r.DatePrecision = datePrecisionFromDatePtr(o.Date)
r.Rating = intFromPtr(o.Rating)
@ -124,16 +126,23 @@ type sceneQueryRow struct {
}
func (r *sceneQueryRow) resolve() *models.Scene {
var coverImageSource *string
if r.CoverImageSource.Valid {
v := r.CoverImageSource.String
coverImageSource = &v
}
ret := &models.Scene{
ID: r.ID,
Title: r.Title.String,
Code: r.Code.String,
Details: r.Details.String,
Director: r.Director.String,
Date: r.Date.DatePtr(r.DatePrecision),
Rating: nullIntPtr(r.Rating),
Organized: r.Organized,
StudioID: nullIntPtr(r.StudioID),
ID: r.ID,
Title: r.Title.String,
Code: r.Code.String,
Details: r.Details.String,
Director: r.Director.String,
CoverImageSource: coverImageSource,
Date: r.Date.DatePtr(r.DatePrecision),
Rating: nullIntPtr(r.Rating),
Organized: r.Organized,
StudioID: nullIntPtr(r.StudioID),
PrimaryFileID: nullIntFileIDPtr(r.PrimaryFileID),
OSHash: r.PrimaryFileOshash.String,
@ -162,6 +171,7 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) {
r.setNullString("code", o.Code)
r.setNullString("details", o.Details)
r.setNullString("director", o.Director)
r.setNullString("cover_image_source", o.CoverImageSource)
r.setNullDate("date", "date_precision", o.Date)
r.setNullInt("rating", o.Rating)
r.setBool("organized", o.Organized)

View file

@ -21,6 +21,7 @@ fragment SceneData on Scene {
last_played_at
play_duration
play_count
cover_image_source
play_history
o_history

View file

@ -160,6 +160,10 @@ interface ISceneParams {
id: string;
}
type SceneUpdateInputWithCoverSource = GQL.SceneUpdateInput & {
cover_image_source?: string | null;
};
const ScenePageTabs = PatchContainerComponent<IProps>("ScenePage.Tabs");
const ScenePageTabContent = PatchContainerComponent<IProps>(
"ScenePage.TabContent"
@ -385,12 +389,23 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
}
async function onGenerateScreenshot(at?: number) {
const input: SceneUpdateInputWithCoverSource = {
id: scene.id,
cover_image_source:
typeof at === "number" ? `timestamp:${at}` : "default",
};
await generateScreenshot({
variables: {
id: scene.id,
at,
},
});
await updateScene({
variables: {
input,
},
});
Toast.success(intl.formatMessage({ id: "toast.generating_screenshot" }));
}
@ -468,20 +483,6 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
>
<FormattedMessage id="actions.generate" />
</Dropdown.Item>
<Dropdown.Item
key="generate-screenshot"
className="bg-secondary text-white"
onClick={() => onGenerateScreenshot(getPlayerPosition())}
>
<FormattedMessage id="actions.generate_thumb_from_current" />
</Dropdown.Item>
<Dropdown.Item
key="generate-default"
className="bg-secondary text-white"
onClick={() => onGenerateScreenshot()}
>
<FormattedMessage id="actions.generate_thumb_default" />
</Dropdown.Item>
{boxes.length > 0 && (
<Dropdown.Item
key="submit"
@ -640,6 +641,10 @@ const ScenePage: React.FC<IProps> = PatchComponent("ScenePage", (props) => {
<SceneEditPanel
isVisible={activeTabKey === "scene-edit-panel"}
scene={scene}
onGenerateThumbFromCurrent={() =>
onGenerateScreenshot(getPlayerPosition())
}
onGenerateThumbDefault={() => onGenerateScreenshot()}
onSubmit={onSave}
onDelete={() => setIsDeleteAlertOpen(true)}
/>

View file

@ -64,6 +64,8 @@ interface IProps {
initialCoverImage?: string;
isNew?: boolean;
isVisible: boolean;
onGenerateThumbFromCurrent?: () => Promise<void>;
onGenerateThumbDefault?: () => Promise<void>;
onSubmit: (input: GQL.SceneCreateInput, andNew?: boolean) => Promise<void>;
onDelete?: () => void;
}
@ -73,6 +75,8 @@ export const SceneEditPanel: React.FC<IProps> = ({
initialCoverImage,
isNew = false,
isVisible,
onGenerateThumbFromCurrent,
onGenerateThumbDefault,
onSubmit,
onDelete,
}) => {
@ -145,6 +149,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
details: yup.string().ensure(),
cover_image: yup.string().nullable().optional(),
cover_image_source: yup.string().nullable().optional(),
custom_fields: yup.object().required().defined(),
});
@ -165,6 +170,9 @@ export const SceneEditPanel: React.FC<IProps> = ({
stash_ids: getStashIDs(scene.stash_ids),
details: scene.details ?? "",
cover_image: initialCoverImage,
cover_image_source:
(scene as { cover_image_source?: string | null }).cover_image_source ??
null,
custom_fields: cloneDeep(scene.custom_fields ?? {}),
}),
[scene, initialCoverImage]
@ -173,12 +181,18 @@ export const SceneEditPanel: React.FC<IProps> = ({
type InputValues = yup.InferType<typeof schema>;
const [customFieldsError, setCustomFieldsError] = useState<string>();
const [coverImageSourceDirty, setCoverImageSourceDirty] = useState(false);
const [coverImageRefreshToken, setCoverImageRefreshToken] = useState(0);
function submit(values: InputValues) {
const input = {
...schema.cast(values),
custom_fields: formatCustomFieldInput(isNew, values.custom_fields),
};
if (!coverImageSourceDirty) {
delete (input as { cover_image_source?: string | null })
.cover_image_source;
}
onSave(input);
}
@ -200,12 +214,22 @@ export const SceneEditPanel: React.FC<IProps> = ({
if (formImage === null && sceneImage) {
const sceneImageURL = new URL(sceneImage);
sceneImageURL.searchParams.set("default", "true");
if (coverImageRefreshToken > 0) {
sceneImageURL.searchParams.set(
"refresh",
coverImageRefreshToken.toString()
);
}
return sceneImageURL.toString();
} else if (formImage) {
return formImage;
}
return sceneImage;
}, [formik.values.cover_image, scene.paths?.screenshot]);
}, [
coverImageRefreshToken,
formik.values.cover_image,
scene.paths?.screenshot,
]);
const groupEntries = useMemo(() => {
return formik.values.groups
@ -259,6 +283,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
}
});
useEffect(() => {
setCoverImageSourceDirty(false);
setCoverImageRefreshToken(0);
}, [scene.id]);
useEffect(() => {
const toFilter = Scrapers?.data?.listScrapers ?? [];
@ -298,6 +327,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
try {
await onSubmit(input, andNew);
formik.resetForm();
setCoverImageSourceDirty(false);
} catch (e) {
Toast.error(e);
}
@ -312,18 +342,54 @@ export const SceneEditPanel: React.FC<IProps> = ({
onSave(input, true);
}
const encodingImage = ImageUtils.usePasteImage(onImageLoad);
const encodingImage = ImageUtils.usePasteImage((imageData) =>
onImageLoad(imageData, "clipboard")
);
function onImageLoad(imageData: string) {
function setCoverImageSource(source: string | null) {
formik.setFieldValue("cover_image_source", source);
setCoverImageSourceDirty(true);
}
function onImageLoad(imageData: string, source?: string) {
formik.setFieldValue("cover_image", imageData);
if (source) {
setCoverImageSource(source);
}
}
function onImageURLSource(source: "url" | "clipboard", value?: string) {
if (source === "clipboard") {
setCoverImageSource("clipboard");
return;
}
if (source === "url") {
setCoverImageSource(`url:${(value ?? "").trim()}`);
}
}
function onCoverImageChange(event: React.FormEvent<HTMLInputElement>) {
ImageUtils.onImageChange(event, onImageLoad);
const input = event.currentTarget as HTMLInputElement;
const fileName = input.files?.[0]?.name?.trim() ?? "";
setCoverImageSource(`file:${fileName}`);
ImageUtils.onImageChange(event, (imageData) => onImageLoad(imageData));
}
function onResetCover() {
formik.setFieldValue("cover_image", null);
setCoverImageSource(null);
}
async function onGenerateDefaultCoverImage() {
if (!onGenerateThumbDefault) {
return;
}
await onGenerateThumbDefault();
formik.setFieldValue("cover_image", null);
setCoverImageSource("default");
setCoverImageRefreshToken(Date.now());
}
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
@ -536,6 +602,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
if (updatedScene.image) {
// image is a base64 string
formik.setFieldValue("cover_image", updatedScene.image);
setCoverImageSource(endpoint ? `stash:${endpoint}` : "url:scraper");
}
if (updatedScene.remote_site_id && endpoint) {
@ -877,15 +944,22 @@ export const SceneEditPanel: React.FC<IProps> = ({
{renderDetailsField()}
<Form.Group controlId="cover_image">
<Form.Label>
<FormattedMessage id="cover_image" />
{intl.formatMessage({ id: "cover_image" })}
</Form.Label>
{image}
<ImageInput
isEditing
onImageChange={onCoverImageChange}
onImageURL={onImageLoad}
onReset={scene.id ? onResetCover : undefined}
/>
<div className="mt-2 d-flex align-items-center">
{!isNew && (
<ImageInput
isEditing
onImageChange={onCoverImageChange}
onImageURL={onImageLoad}
onImageURLSource={onImageURLSource}
onReset={onResetCover}
onGenerateDefault={onGenerateDefaultCoverImage}
onGenerateCurrent={onGenerateThumbFromCurrent}
/>
)}
</div>
</Form.Group>
<CustomFieldsInput

View file

@ -14,16 +14,50 @@ import { ReassignFilesDialog } from "src/components/Shared/ReassignFilesDialog";
import * as GQL from "src/core/generated-graphql";
import { mutateSceneSetPrimaryFile } from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
import { useConfigurationContext } from "src/hooks/Config";
import NavUtils from "src/utils/navigation";
import TextUtils from "src/utils/text";
import { TextField, URLField, URLsField } from "src/utils/field";
import { IconField, TextField, URLField, URLsField } from "src/utils/field";
import { StashIDPill } from "src/components/Shared/StashID";
import { PatchComponent } from "../../../patch";
import { FileSize } from "src/components/Shared/FileSize";
import {
faCameraRotate,
faClipboard,
faFileCode,
faFile,
faLink,
faPhotoFilm,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
const pad2 = (value: number) => value.toString().padStart(2, "0");
function formatCoverTimestamp(seconds: number, frameRate?: number | null) {
const fps = frameRate ?? 0;
const roundedSeconds =
Number.isFinite(fps) && fps > 0
? Math.round(seconds * fps) / fps
: Math.round(seconds * 100) / 100;
const centiseconds = Math.round(roundedSeconds * 100);
const totalSeconds = Math.floor(centiseconds / 100);
const cs = centiseconds % 100;
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
if (hours > 0) {
return `${hours}:${pad2(minutes)}:${pad2(secs)}.${pad2(cs)}`;
}
return `${minutes}:${pad2(secs)}.${pad2(cs)}`;
}
interface IFileInfoPanelProps {
sceneID: string;
file: GQL.VideoFileDataFragment;
coverImageSource?: string | null;
primary?: boolean;
ofMany?: boolean;
onSetPrimaryFile?: () => void;
@ -32,17 +66,120 @@ interface IFileInfoPanelProps {
loading?: boolean;
}
interface ICoverSourceData {
icon?: FontAwesomeIconProps["icon"];
value: string;
url?: string | null;
stashEndpoint?: string | null;
tooltip?: string;
}
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
props: IFileInfoPanelProps
) => {
const intl = useIntl();
const history = useHistory();
const { configuration } = useConfigurationContext();
const coverImageLabel = intl.formatMessage({ id: "scene_cover.image" });
const coverSourceLabel = intl.formatMessage(
{ id: "scene_cover.source" },
{ image: coverImageLabel }
);
// TODO - generalise fingerprints
const oshash = props.file.fingerprints.find((f) => f.type === "oshash");
const phash = props.file.fingerprints.find((f) => f.type === "phash");
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
const coverSourceField = useMemo(() => {
const getCoverSourceTooltip = (id: string) =>
intl.formatMessage({ id }, { image: coverImageLabel });
const source = props.coverImageSource;
if (!source) {
return null;
}
let coverSourceData: ICoverSourceData;
if (source === "default") {
coverSourceData = {
icon: faPhotoFilm,
value: intl.formatMessage({ id: "scene_cover.default" }),
tooltip: getCoverSourceTooltip("scene_cover.default_tooltip"),
};
} else if (source === "clipboard") {
coverSourceData = {
icon: faClipboard,
value: intl.formatMessage({ id: "scene_cover.clipboard" }),
tooltip: getCoverSourceTooltip("scene_cover.clipboard_tooltip"),
};
} else if (source === "userscript") {
coverSourceData = {
icon: faFileCode,
value: intl.formatMessage({ id: "scene_cover.userscript" }),
tooltip: getCoverSourceTooltip("scene_cover.userscript_tooltip"),
};
} else if (source.startsWith("file:")) {
const fileName = source.slice("file:".length);
coverSourceData = {
icon: faFile,
value: fileName || intl.formatMessage({ id: "scene_cover.file" }),
tooltip: getCoverSourceTooltip("scene_cover.file_tooltip"),
};
} else if (source.startsWith("url:")) {
const urlValue = source.slice("url:".length).trim();
coverSourceData = {
icon: faLink,
value: urlValue || intl.formatMessage({ id: "actions.from_url" }),
url: urlValue || null,
tooltip: getCoverSourceTooltip("scene_cover.url_tooltip"),
};
} else if (source.startsWith("timestamp:")) {
const rawTimestamp = source.slice("timestamp:".length).trim();
const parsedTimestamp = Number.parseFloat(rawTimestamp);
const timestampLabel = Number.isFinite(parsedTimestamp)
? formatCoverTimestamp(parsedTimestamp, props.file.frame_rate)
: rawTimestamp;
coverSourceData = {
icon: faCameraRotate,
value: timestampLabel,
tooltip: getCoverSourceTooltip("scene_cover.timestamp_tooltip"),
};
} else if (source.startsWith("stash:")) {
const endpoint = source.slice("stash:".length).trim();
const endpointName =
configuration?.general.stashBoxes.find((sb) => sb.endpoint === endpoint)
?.name ?? endpoint;
coverSourceData = {
value: endpointName,
stashEndpoint: endpointName,
tooltip: getCoverSourceTooltip("scene_cover.stash_tooltip"),
};
} else {
coverSourceData = { value: source };
}
return (
<IconField
name={coverSourceLabel}
icon={coverSourceData.icon}
value={coverSourceData.value}
url={coverSourceData.url}
stashEndpoint={coverSourceData.stashEndpoint}
truncate={Boolean(coverSourceData.url)}
tooltip={coverSourceData.tooltip}
/>
);
}, [
configuration?.general.stashBoxes,
coverImageLabel,
coverSourceLabel,
intl,
props.coverImageSource,
props.file.frame_rate,
]);
function onSplit() {
history.push(
`/scenes/new?from_scene_id=${props.sceneID}&file_id=${props.file.id}`
@ -76,6 +213,7 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
truncate
internal
/>
{coverSourceField}
<TextField id="path">
<span className="d-flex align-items-center">
<TruncatedText text={props.file.path} />
@ -171,6 +309,9 @@ const _SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
props: ISceneFileInfoPanelProps
) => {
const Toast = useToast();
const coverImageSource =
(props.scene as { cover_image_source?: string | null })
.cover_image_source ?? null;
const [loading, setLoading] = useState(false);
const [deletingFile, setDeletingFile] = useState<GQL.VideoFileDataFragment>();
@ -232,7 +373,11 @@ const _SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
if (props.scene.files.length === 1) {
return (
<FileInfoPanel sceneID={props.scene.id} file={props.scene.files[0]} />
<FileInfoPanel
sceneID={props.scene.id}
file={props.scene.files[0]}
coverImageSource={coverImageSource}
/>
);
}
@ -271,6 +416,7 @@ const _SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
<FileInfoPanel
sceneID={props.scene.id}
file={file}
coverImageSource={coverImageSource}
primary={index === 0}
ofMany
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
@ -284,7 +430,14 @@ const _SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
))}
</Accordion>
);
}, [props.scene, loading, Toast, deletingFile, reassigningFile]);
}, [
props.scene,
loading,
Toast,
deletingFile,
reassigningFile,
coverImageSource,
]);
return (
<>

View file

@ -625,45 +625,63 @@ const SceneMergeDetails: React.FC<ISceneMergeDetailsProps> = ({
// only set the cover image if it's different from the existing cover image
const coverImage = image.useNewValue ? image.getNewValue() : undefined;
const sourceCoverImageSource = (
sources.find((s) => s.paths.screenshot) as unknown as
| { cover_image_source?: string | null }
| undefined
)?.cover_image_source;
const coverImageSource = image.useNewValue
? coverImage
? sourceCoverImageSource ?? null
: null
: undefined;
const values: GQL.SceneUpdateInput = {
id: dest.id,
title: title.getNewValue(),
code: code.getNewValue(),
urls: url.getNewValue(),
date: date.getNewValue(),
rating100: rating.getNewValue(),
o_counter: oCounter.getNewValue(),
play_count: playCount.getNewValue(),
play_duration: playDuration.getNewValue(),
gallery_ids: galleries.getNewValue(),
studio_id: studio.getNewValue()?.stored_id,
performer_ids: performers.getNewValue()?.map((p) => p.stored_id!),
groups: groups.getNewValue()?.map((m) => {
// find the equivalent group in the original scenes
const found = all
.map((s) => s.groups)
.flat()
.find((mm) => mm.group.id === m.stored_id);
return {
group_id: m.stored_id!,
scene_index: found!.scene_index,
};
}),
tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),
details: details.getNewValue(),
organized: organized.getNewValue(),
stash_ids: stashIDs.getNewValue(),
cover_image: coverImage,
custom_fields: {
partial: Object.fromEntries(
Array.from(customFields.entries()).flatMap(([field, v]) =>
v.useNewValue ? [[field, v.getNewValue()]] : []
)
),
},
};
if (coverImageSource !== undefined) {
(
values as unknown as { cover_image_source?: string | null }
).cover_image_source = coverImageSource;
}
return {
values: {
id: dest.id,
title: title.getNewValue(),
code: code.getNewValue(),
urls: url.getNewValue(),
date: date.getNewValue(),
rating100: rating.getNewValue(),
o_counter: oCounter.getNewValue(),
play_count: playCount.getNewValue(),
play_duration: playDuration.getNewValue(),
gallery_ids: galleries.getNewValue(),
studio_id: studio.getNewValue()?.stored_id,
performer_ids: performers.getNewValue()?.map((p) => p.stored_id!),
groups: groups.getNewValue()?.map((m) => {
// find the equivalent group in the original scenes
const found = all
.map((s) => s.groups)
.flat()
.find((mm) => mm.group.id === m.stored_id);
return {
group_id: m.stored_id!,
scene_index: found!.scene_index,
};
}),
tag_ids: tags.getNewValue()?.map((t) => t.stored_id!),
details: details.getNewValue(),
organized: organized.getNewValue(),
stash_ids: stashIDs.getNewValue(),
cover_image: coverImage,
custom_fields: {
partial: Object.fromEntries(
Array.from(customFields.entries()).flatMap(([field, v]) =>
v.useNewValue ? [[field, v.getNewValue()]] : []
)
),
},
},
values,
includeViewHistory: playCount.getNewValue() !== undefined,
includeOHistory: oCounter.getNewValue() !== undefined,
};

View file

@ -10,7 +10,14 @@ import {
import { useIntl } from "react-intl";
import { ModalComponent } from "./Modal";
import { Icon } from "./Icon";
import { faClipboard, faFile, faLink } from "@fortawesome/free-solid-svg-icons";
import {
faCameraRotate,
faCirclePlay,
faClipboard,
faFile,
faLink,
faPhotoFilm,
} from "@fortawesome/free-solid-svg-icons";
import { PatchComponent } from "src/patch";
import ImageUtils from "src/utils/image";
import { useToast } from "src/hooks/Toast";
@ -20,8 +27,11 @@ interface IImageInput {
text?: string;
onImageChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
onImageURL?: (url: string) => void;
onImageURLSource?: (source: "url" | "clipboard", value?: string) => void;
onReset?: () => void;
acceptSVG?: boolean;
onGenerateDefault?: () => void;
onGenerateCurrent?: () => void;
}
function acceptExtensions(acceptSVG: boolean = false) {
@ -35,14 +45,16 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
text,
onImageChange,
onImageURL,
onImageURLSource,
onReset,
acceptSVG = false,
onGenerateDefault,
onGenerateCurrent,
}) => {
const [isShowDialog, setIsShowDialog] = useState(false);
const [url, setURL] = useState("");
const intl = useIntl();
const Toast = useToast();
if (!isEditing) return <div />;
if (!onImageURL) {
@ -66,6 +78,7 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
const data = await ImageUtils.readClipboardImage();
if (data && onImageURL) {
onImageURL(data);
onImageURLSource?.("clipboard");
Toast.success(
intl.formatMessage({ id: "toast.clipboard_image_pasted" })
);
@ -95,6 +108,7 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
setIsShowDialog(false);
onImageURL(url);
onImageURLSource?.("url", url);
}
function renderDialog() {
@ -162,6 +176,46 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
</Button>
</div>
)}
{(onGenerateDefault || onGenerateCurrent) && (
<hr className="my-2" />
)}
{onGenerateDefault && (
<div>
<Button className="minimal" onClick={onGenerateDefault}>
<Icon icon={faPhotoFilm} className="fa-fw" />
<span>
{intl.formatMessage({
id: "actions.generate_thumb_default",
})}
</span>
</Button>
</div>
)}
{onGenerateCurrent && (
<div>
<Button className="minimal" onClick={onGenerateCurrent}>
<Icon icon={faCameraRotate} className="fa-fw" />
<span>
{intl.formatMessage({
id: "actions.generate_thumb_from_current",
})}
</span>
</Button>
</div>
)}
{onReset && (
<>
<hr className="my-2" />
<div>
<Button className="minimal" onClick={onReset}>
<Icon icon={faCirclePlay} className="fa-solid" />
<span>
{intl.formatMessage({ id: "actions.clear_image" })}
</span>
</Button>
</div>
</>
)}
</>
</Popover.Content>
</Popover>
@ -180,11 +234,6 @@ export const ImageInput: React.FC<IImageInput> = PatchComponent(
{text ?? intl.formatMessage({ id: "actions.set_image" })}
</Button>
</OverlayTrigger>
{onReset && (
<Button variant="danger" className="mr-2" onClick={onReset}>
{intl.formatMessage({ id: "actions.clear_image" })}
</Button>
)}
</>
);
}

View file

@ -383,6 +383,14 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
director: resolveField("director", stashScene.director, scene.director),
};
if (imgData) {
(
sceneCreateInput as unknown as { cover_image_source?: string }
).cover_image_source = currentSource?.sourceInput.stash_box_endpoint
? `stash:${currentSource.sourceInput.stash_box_endpoint}`
: "url:scraper";
}
const includeUrl = !excludedFieldList.includes("url");
if (includeUrl && scene.urls) {
sceneCreateInput.urls = uniq(stashScene.urls.concat(scene.urls));

View file

@ -64,8 +64,8 @@
"full_export": "Full export",
"full_import": "Full import",
"generate": "Generate",
"generate_thumb_default": "Generate default thumbnail",
"generate_thumb_from_current": "Generate thumbnail from current",
"generate_thumb_default": "Generate default",
"generate_thumb_from_current": "Generate from current",
"hash_migration": "hash migration",
"hide": "Hide",
"hide_configuration": "Hide Configuration",
@ -1419,6 +1419,22 @@
"sceneTagger": "Scene Tagger",
"scene_code": "Studio Code",
"scene_count": "Scene Count",
"scene_cover": {
"clipboard": "Clipboard",
"clipboard_tooltip": "{image} imported from clipboard by user",
"default": "Default",
"default_tooltip": "{image} generated from default settings",
"file": "File",
"file_tooltip": "{image} from file",
"image": "Cover Image",
"source": "{image} Source",
"stash_tooltip": "{image} from StashBox",
"timestamp_tooltip": "{image} extracted at timestamp",
"url": "URL",
"url_tooltip": "{image} from URL",
"userscript": "Userscript",
"userscript_tooltip": "{image} set via userscript"
},
"scenes_duration": "Scene Duration",
"scenes_size": "Scene Size",
"scene_created_at": "Scene Created At",

View file

@ -1,7 +1,9 @@
import React from "react";
import { FontAwesomeIconProps } from "@fortawesome/react-fontawesome";
import { FormattedMessage } from "react-intl";
import { Link } from "react-router-dom";
import { ExternalLink } from "src/components/Shared/ExternalLink";
import { Icon } from "src/components/Shared/Icon";
import { TruncatedText } from "src/components/Shared/TruncatedText";
interface ITextField {
@ -155,3 +157,94 @@ export const URLsField: React.FC<IURLsField> = ({
</>
);
};
interface IIconField {
id?: string;
name?: string;
abbr?: string | null;
icon?: FontAwesomeIconProps["icon"];
tooltip?: string;
value?: string | null;
url?: string | null;
stashEndpoint?: string | null;
truncate?: boolean | null;
target?: string;
}
export const IconField: React.FC<IIconField> = ({
id,
name,
abbr,
icon,
tooltip,
value,
url,
stashEndpoint,
truncate,
target = "_blank",
}) => {
if (!value && !url && !stashEndpoint) {
return null;
}
const message = (
<>{id ? <FormattedMessage id={id} defaultMessage={name} /> : name}:</>
);
const displayValue = value ?? url ?? stashEndpoint ?? "";
function renderValue() {
if (stashEndpoint) {
return (
<span className="stash-id-pill" data-endpoint={stashEndpoint}>
<span>{displayValue}</span>
</span>
);
}
if (url) {
const children = truncate ? (
<TruncatedText
text={displayValue}
className="d-inline-block align-middle"
/>
) : (
displayValue
);
return (
<ExternalLink
href={url}
target={target}
title={url}
className="flex-grow-1"
>
{children}
</ExternalLink>
);
}
return displayValue;
}
return (
<>
<dt>{abbr ? <abbr title={abbr}>{message}</abbr> : message}</dt>
<dd>
<span
title={tooltip}
className={
url
? "d-inline-flex align-items-center w-100"
: "d-inline-flex align-items-center"
}
style={url ? { minWidth: 0 } : undefined}
>
{icon && (
<Icon icon={icon} className={url ? "mr-1 flex-shrink-0" : "mr-1"} />
)}
{renderValue()}
</span>
</dd>
</>
);
};