Skip to content

[feature] UEPR-252 Implement manual update of project thumbnails #264

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

Merged
merged 8 commits into from
Jul 22, 2025
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
9 changes: 9 additions & 0 deletions packages/scratch-gui/src/components/gui/gui.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ const GUIComponent = props => {
isTotallyNormal,
loading,
logo,
manuallySaveThumbnails,
renderLogin,
onClickAbout,
onClickAccountNav,
Expand Down Expand Up @@ -128,6 +129,7 @@ const GUIComponent = props => {
onTelemetryModalCancel,
onTelemetryModalOptIn,
onTelemetryModalOptOut,
onUpdateProjectThumbnail,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thumbnail saves are currently done through the Storage interface - there is a saveProjectThumbnail method that is currently called. Is that still called to save the thumbnail to the server? And this is just to notify that the user saved it, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed offline, the store's saveProjectThumbnail is used as fallback when the onUpdate callback isn't passed - which is the case in NGP

showComingSoon,
soundsTabVisible,
stageSizeMode,
Expand Down Expand Up @@ -181,6 +183,11 @@ const GUIComponent = props => {
isRendererSupported={isRendererSupported}
isRtl={isRtl}
loading={loading}
manuallySaveThumbnails={
manuallySaveThumbnails &&
userOwnsProject
}
onUpdateProjectThumbnail={onUpdateProjectThumbnail}
stageSize={STAGE_SIZE_MODES.large}
vm={vm}
>
Expand Down Expand Up @@ -457,6 +464,7 @@ GUIComponent.propTypes = {
isTotallyNormal: PropTypes.bool,
loading: PropTypes.bool,
logo: PropTypes.string,
manuallySaveThumbnails: PropTypes.bool,
onActivateCostumesTab: PropTypes.func,
onActivateSoundsTab: PropTypes.func,
onActivateTab: PropTypes.func,
Expand All @@ -481,6 +489,7 @@ GUIComponent.propTypes = {
onTelemetryModalOptIn: PropTypes.func,
onTelemetryModalOptOut: PropTypes.func,
onToggleLoginOpen: PropTypes.func,
onUpdateProjectThumbnail: PropTypes.func,
platform: PropTypes.oneOf(Object.keys(PLATFORM)),
renderLogin: PropTypes.func,
showComingSoon: PropTypes.bool,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,23 @@
[dir="rtl"] .stage-button-icon {
transform: scaleX(-1);
}

.rightSection {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
background-color: transparent;

.setThumbnailButton {
padding: 0.625rem 0.75rem;
font-size: 0.75rem;
line-height: 0.875rem;
color: $ui-white;
background-color: $motion-primary;
}

.setThumbnailButton:active {
filter: brightness(90%);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {defineMessages, injectIntl, intlShape} from 'react-intl';
import {FormattedMessage, defineMessages, injectIntl, intlShape} from 'react-intl';
import PropTypes from 'prop-types';
import React from 'react';
import React, {useCallback} from 'react';
import {connect} from 'react-redux';
import VM from '@scratch/scratch-vm';

Expand All @@ -18,6 +18,9 @@ import unFullScreenIcon from './icon--unfullscreen.svg';

import scratchLogo from '../menu-bar/scratch-logo.svg';
import styles from './stage-header.css';
import {storeProjectThumbnail} from '../../lib/store-project-thumbnail.js';
import dataURItoBlob from '../../lib/data-uri-to-blob.js';
import throttle from 'lodash.throttle';

const messages = defineMessages({
largeStageSizeMessage: {
Expand All @@ -40,6 +43,11 @@ const messages = defineMessages({
description: 'Button to get out of full screen mode',
id: 'gui.stageHeader.stageSizeUnFull'
},
setThumbnail: {
defaultMessage: 'Set Thumbnail',
description: 'Manually save project thumbnail',
id: 'gui.stageHeader.saveThumbnail'
},
fullscreenControl: {
defaultMessage: 'Full Screen Control',
description: 'Button to enter/exit full screen mode',
Expand All @@ -51,18 +59,37 @@ const StageHeaderComponent = function (props) {
const {
isFullScreen,
isPlayerOnly,
manuallySaveThumbnails,
onKeyPress,
onSetStageLarge,
onSetStageSmall,
onSetStageFull,
onSetStageUnFull,
onUpdateProjectThumbnail,
projectId,
showBranding,
stageSizeMode,
vm
} = props;

let header = null;

const onUpdateThumbnail = useCallback(
throttle(
() => {
if (!onUpdateProjectThumbnail) {
return;
}

storeProjectThumbnail(vm, dataURI => {
onUpdateProjectThumbnail(projectId, dataURItoBlob(dataURI));
});
},
3000
),
[projectId, onUpdateProjectThumbnail]
);

if (isFullScreen) {
const stageDimensions = getStageDimensions(null, true);
const stageButton = showBranding ? (
Expand Down Expand Up @@ -138,7 +165,16 @@ const StageHeaderComponent = function (props) {
<Controls vm={vm} />
<div className={styles.stageSizeRow}>
{stageControls}
<div>
<div className={styles.rightSection}>
{manuallySaveThumbnails && (
<Button
aria-label={props.intl.formatMessage(messages.setThumbnail)}
className={styles.setThumbnailButton}
onClick={onUpdateThumbnail}
>
<FormattedMessage {...messages.setThumbnail} />
</Button>
)}
<Button
className={styles.stageButton}
onClick={onSetStageFull}
Expand All @@ -162,6 +198,7 @@ const StageHeaderComponent = function (props) {
};

const mapStateToProps = state => ({
projectId: state.scratchGui.projectState.projectId,
// This is the button's mode, as opposed to the actual current state
stageSizeMode: state.scratchGui.stageSize.stageSize
});
Expand All @@ -170,11 +207,14 @@ StageHeaderComponent.propTypes = {
intl: intlShape,
isFullScreen: PropTypes.bool.isRequired,
isPlayerOnly: PropTypes.bool.isRequired,
manuallySaveThumbnails: PropTypes.bool,
onKeyPress: PropTypes.func.isRequired,
onSetStageFull: PropTypes.func.isRequired,
onSetStageLarge: PropTypes.func.isRequired,
onSetStageSmall: PropTypes.func.isRequired,
onSetStageUnFull: PropTypes.func.isRequired,
onUpdateProjectThumbnail: PropTypes.func,
projectId: PropTypes.number.isRequired,
showBranding: PropTypes.bool.isRequired,
stageSizeMode: PropTypes.oneOf(Object.keys(STAGE_SIZE_MODES)),
vm: PropTypes.instanceOf(VM).isRequired
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const StageWrapperComponent = function (props) {
isRtl,
isRendererSupported,
loading,
manuallySaveThumbnails,
onUpdateProjectThumbnail,
stageSize,
vm
} = props;
Expand All @@ -31,6 +33,8 @@ const StageWrapperComponent = function (props) {
>
<Box className={styles.stageMenuWrapper}>
<StageHeader
manuallySaveThumbnails={manuallySaveThumbnails}
onUpdateProjectThumbnail={onUpdateProjectThumbnail}
stageSize={stageSize}
vm={vm}
/>
Expand All @@ -57,6 +61,8 @@ StageWrapperComponent.propTypes = {
isRendererSupported: PropTypes.bool.isRequired,
isRtl: PropTypes.bool.isRequired,
loading: PropTypes.bool,
manuallySaveThumbnails: PropTypes.bool,
onUpdateProjectThumbnail: PropTypes.func,
stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired,
vm: PropTypes.instanceOf(VM).isRequired
};
Expand Down
68 changes: 32 additions & 36 deletions packages/scratch-gui/src/lib/project-saver-hoc.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
projectError
} from '../reducers/project-state';
import {GUIStoragePropType} from '../gui-config';
import {getProjectThumbnail, storeProjectThumbnail} from './store-project-thumbnail';

/**
* Higher Order Component to provide behavior for saving projects.
Expand All @@ -47,7 +48,6 @@ const ProjectSaverHOC = function (WrappedComponent) {
constructor (props) {
super(props);
bindAll(this, [
'getProjectThumbnail',
'leavePageConfirm',
'tryToAutoSave'
]);
Expand All @@ -61,7 +61,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
// Allow the GUI consumer to pass in a function to receive a trigger
// for triggering thumbnail or whole project saves.
// These functions are called with null on unmount to prevent stale references.
this.props.onSetProjectThumbnailer(this.getProjectThumbnail);
this.props.onSetProjectThumbnailer(callback => getProjectThumbnail(this.props.vm, callback));
this.props.onSetProjectSaver(this.tryToAutoSave);
}
componentDidUpdate (prevProps) {
Expand All @@ -72,9 +72,21 @@ const ProjectSaverHOC = function (WrappedComponent) {
this.reportTelemetryEvent('projectDidLoad');
}

if (this.props.saveThumbnailOnLoad && this.props.isShowingWithId &&
!prevProps.isShowingWithId) {
setTimeout(() => this.storeProjectThumbnail(this.props.reduxProjectId));
if (
!this.props.manuallySaveThumbnails &&
this.props.onUpdateProjectThumbnail &&
this.props.saveThumbnailOnLoad &&
this.props.isShowingWithId &&
!prevProps.isShowingWithId
) {
setTimeout(() =>
storeProjectThumbnail(this.props.vm, dataURI => {
this.props.onUpdateProjectThumbnail(
this.props.reduxProjectId,
dataURItoBlob(dataURI)
);
})
);
}

if (this.props.projectChanged && !prevProps.projectChanged) {
Expand Down Expand Up @@ -172,7 +184,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
});
}
createNewProjectToStorage () {
return this.storeProject(null)
return this.storeProject(null, {}, {isCreatingProject: true})
.then(response => {
this.props.onCreatedProject(response.id.toString(), this.props.loadingState);
})
Expand Down Expand Up @@ -218,8 +230,9 @@ const ProjectSaverHOC = function (WrappedComponent) {
* @param {number|string|undefined} projectId - defined value will PUT/update; undefined/null will POST/create
* @return {Promise} - resolves with json object containing project's existing or new id
* @param {?object} requestParams - object of params to add to request body
* @param {?object} options - additional options for the store operation
*/
storeProject (projectId, requestParams) {
storeProject (projectId, requestParams, options) {
requestParams = requestParams || {};
this.clearAutoSaveTimeout();
// Serialize VM state now before embarking on
Expand Down Expand Up @@ -256,8 +269,16 @@ const ProjectSaverHOC = function (WrappedComponent) {
.then(response => {
this.props.onSetProjectUnchanged();
const id = response.id.toString();
if (id && this.props.onUpdateProjectThumbnail) {
this.storeProjectThumbnail(id);
if (this.props.onUpdateProjectThumbnail && id && (
!this.props.manuallySaveThumbnails ||
// Always save thumbnail on project creation
options?.isCreatingProject)) {
storeProjectThumbnail(this.props.vm, dataURI => {
this.props.onUpdateProjectThumbnail(
id,
dataURItoBlob(dataURI)
);
});
}
this.reportTelemetryEvent('projectDidSave');
return response;
Expand All @@ -268,32 +289,6 @@ const ProjectSaverHOC = function (WrappedComponent) {
});
}

/**
* Store a snapshot of the project once it has been saved/created.
* Needs to happen _after_ save because the project must have an ID.
* @param {!string} projectId - id of the project, must be defined.
*/
storeProjectThumbnail (projectId) {
try {
this.getProjectThumbnail(dataURI => {
this.props.onUpdateProjectThumbnail(projectId, dataURItoBlob(dataURI));
});
} catch (e) {
log.error('Project thumbnail save error', e);
// This is intentionally fire/forget because a failure
// to save the thumbnail is not vitally important to the user.
}
}

getProjectThumbnail (callback) {
this.props.vm.postIOData('video', {forceTransparentPreview: true});
this.props.vm.renderer.requestSnapshot(dataURI => {
this.props.vm.postIOData('video', {forceTransparentPreview: false});
callback(dataURI);
});
this.props.vm.renderer.draw();
}

/**
* Report a telemetry event.
* @param {string} event - one of `projectWasCreated`, `projectDidLoad`, `projectDidSave`, `projectWasUploaded`
Expand Down Expand Up @@ -346,7 +341,6 @@ const ProjectSaverHOC = function (WrappedComponent) {
onShowSavingAlert,
onUpdatedProject,
onUpdateProjectData,
onUpdateProjectThumbnail,
noBeforeUnloadHandler,
reduxProjectId,
reduxProjectTitle,
Expand Down Expand Up @@ -408,6 +402,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
saveThumbnailOnLoad: PropTypes.bool,
storage: GUIStoragePropType,
setAutoSaveTimeoutId: PropTypes.func.isRequired,
manuallySaveThumbnails: PropTypes.bool,
vm: PropTypes.instanceOf(VM).isRequired
};
ProjectSaverComponent.defaultProps = {
Expand Down Expand Up @@ -437,6 +432,7 @@ const ProjectSaverHOC = function (WrappedComponent) {
isManualUpdating: getIsManualUpdating(loadingState),
loadingState: loadingState,
locale: state.locales.locale,
manuallySaveThumbnails: ownProps.manuallySaveThumbnails ?? false,
onUpdateProjectThumbnail:
ownProps.onUpdateProjectThumbnail ??
storage.saveProjectThumbnail?.bind(storage),
Expand Down
20 changes: 20 additions & 0 deletions packages/scratch-gui/src/lib/store-project-thumbnail.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import log from './log';

export const storeProjectThumbnail = (vm, callback) => {
try {
getProjectThumbnail(vm, callback);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"store" calls "get"? Hm?

Does this also save the project thumbnail to the backend?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, the callback saves it, the vm makes a snapshot of the current state.

} catch (e) {
log.error('Project thumbnail save error', e);
// This is intentionally fire/forget because a failure
// to save the thumbnail is not vitally important to the user.
}
};

export const getProjectThumbnail = (vm, callback) => {
vm.postIOData('video', {forceTransparentPreview: true});
vm.renderer.requestSnapshot(dataURI => {
vm.postIOData('video', {forceTransparentPreview: false});
callback(dataURI);
});
vm.renderer.draw();
};
Loading