Compare commits

...

8 Commits

Author SHA1 Message Date
AlexandreSi
b0c74ed29f Specify filename when generating icons 2024-05-02 17:37:56 +02:00
AlexandreSi
de041e469c Specify file extension in resource metadata 2024-05-02 16:59:52 +02:00
AlexandreSi
d18615c43c Add label to the input 2024-05-02 16:34:08 +02:00
AlexandreSi
56bba125b1 Reset file input value after icons were generated 2024-05-02 16:29:09 +02:00
AlexandreSi
b36c65ea65 Handle image generation errors 2024-05-02 16:23:45 +02:00
AlexandreSi
f70adcf382 Use blobs instead of the filesystem to generate icons 2024-05-02 15:37:52 +02:00
AlexandreSi
33d26908ee Add TODOs 2024-05-02 11:51:25 +02:00
AlexandreSi
36aca017fb Read image from url when setting platform specific assets thumbnails 2024-05-02 11:47:50 +02:00
2 changed files with 127 additions and 154 deletions

View File

@@ -1,33 +1,13 @@
//@flow //@flow
import optionalRequire from '../Utils/OptionalRequire';
const fs = optionalRequire('fs-extra');
export const getImageFromPath = (path: string): Promise<HTMLImageElement> => {
const imageElement = document.createElement('img');
const file = fs.readFileSync(path, { encoding: 'base64' });
return new Promise<HTMLImageElement>((resolve, reject) => {
imageElement.addEventListener('error', (event: Event) => {
reject(event);
});
imageElement.addEventListener('load', () => {
resolve(imageElement);
});
imageElement.src = `data:image/png;base64,${file}`;
});
};
export const resizeImage = ( export const resizeImage = (
image: HTMLImageElement, imageAsBlobDataUrl: string,
outputFile: string,
{ {
width, width,
height, height,
transparentBorderSize = 0, transparentBorderSize = 0,
}: {| width: number, height: number, transparentBorderSize?: number |} }: {| width: number, height: number, transparentBorderSize?: number |}
): Promise<boolean> => { ): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const canvasElement = document.createElement('canvas'); const canvasElement = document.createElement('canvas');
canvasElement.width = width; canvasElement.width = width;
@@ -36,20 +16,27 @@ export const resizeImage = (
ctx.imageSmoothingEnabled = true; ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high'; ctx.imageSmoothingQuality = 'high';
const image = new Image();
image.addEventListener('load', () => {
try {
ctx.drawImage(
image,
transparentBorderSize,
transparentBorderSize,
width - 2 * transparentBorderSize,
height - 2 * transparentBorderSize
);
ctx.drawImage( canvasElement.toBlob(blob => {
image, resolve(URL.createObjectURL(blob));
transparentBorderSize, }, 'image/png');
transparentBorderSize, } catch (error) {
width - 2 * transparentBorderSize, reject('An error occurred while generating an icon');
height - 2 * transparentBorderSize }
); });
image.addEventListener('error', (e: Event) => {
canvasElement.toBlob(blob => { reject('An error occurred while loading the input image');
blob.arrayBuffer().then(buffer => { });
fs.writeFileSync(outputFile, Buffer.from(buffer)); image.src = imageAsBlobDataUrl;
resolve(true);
});
}, 'image/png');
}); });
}; };

View File

@@ -5,21 +5,18 @@ import { I18n } from '@lingui/react';
import * as React from 'react'; import * as React from 'react';
import FlatButton from '../UI/FlatButton'; import FlatButton from '../UI/FlatButton';
import RaisedButton from '../UI/RaisedButton';
import Dialog, { DialogPrimaryButton } from '../UI/Dialog'; import Dialog, { DialogPrimaryButton } from '../UI/Dialog';
import { Line } from '../UI/Grid'; import { Line } from '../UI/Grid';
import ResourcesLoader from '../ResourcesLoader'; import ResourcesLoader from '../ResourcesLoader';
import ResourceSelectorWithThumbnail from '../ResourcesList/ResourceSelectorWithThumbnail'; import ResourceSelectorWithThumbnail from '../ResourcesList/ResourceSelectorWithThumbnail';
import { type ResourceManagementProps } from '../ResourcesList/ResourceSource'; import { type ResourceManagementProps } from '../ResourcesList/ResourceSource';
import { getImageFromPath, resizeImage } from './ImageResizer'; import { resizeImage } from './ImageResizer';
import { showErrorBox } from '../UI/Messages/MessageBox'; import { showErrorBox } from '../UI/Messages/MessageBox';
import optionalRequire from '../Utils/OptionalRequire';
import Text from '../UI/Text'; import Text from '../UI/Text';
import { ColumnStackLayout } from '../UI/Layout'; import { ColumnStackLayout, ResponsiveLineStackLayout } from '../UI/Layout';
import AlertMessage from '../UI/AlertMessage'; import AlertMessage from '../UI/AlertMessage';
import ErrorBoundary from '../UI/ErrorBoundary'; import ErrorBoundary from '../UI/ErrorBoundary';
const path = optionalRequire('path');
const gd: libGDevelop = global.gd; const gd: libGDevelop = global.gd;
type Props = {| type Props = {|
@@ -70,6 +67,8 @@ const iosSizes = [
]; ];
class PlatformSpecificAssetsDialog extends React.Component<Props, State> { class PlatformSpecificAssetsDialog extends React.Component<Props, State> {
inputRef: HTMLInputElement | null = null;
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = this._loadFrom(props.project); this.state = this._loadFrom(props.project);
@@ -105,118 +104,107 @@ class PlatformSpecificAssetsDialog extends React.Component<Props, State> {
} }
} }
_generateFromFile = async () => { _generateFromFile = async e => {
const { project, resourceManagementProps } = this.props; if (!this.inputRef || !this.inputRef.files || !this.inputRef.files[0]) {
console.error("Could't find selected file. Aborting icon generation.");
const resourceSource = resourceManagementProps.resourceSources
.filter(source => source.kind === 'image')
.filter(source => source.name.startsWith('local-file-opener'))[0];
if (!resourceSource) {
throw new Error(
'No supported resource source - only local files are supported.'
);
}
const resources = await resourceManagementProps.onChooseResource({
initialSourceName: resourceSource.name,
multiSelection: false,
resourceKind: 'image',
});
if (!resources.length || !path) {
return; return;
} }
const chosenFileAsBlobDataUrl = URL.createObjectURL(this.inputRef.files[0]);
const { project, resourceManagementProps } = this.props;
const resourcesManager = project.getResourcesManager(); const resourcesManager = project.getResourcesManager();
const projectPath = path.dirname(project.getProjectFile());
const fullPath = path.resolve(projectPath, resources[0].getFile());
const image = await getImageFromPath(fullPath); try {
const results = await Promise.all([
...desktopSizes.map(async size => ({
resourceName: `desktop-icon-${size}.png`,
blobDataUrl: await resizeImage(chosenFileAsBlobDataUrl, {
width: size,
height: size,
}),
})),
...androidSizes.map(async size => ({
resourceName: `android-icon-${size}.png`,
blobDataUrl: await resizeImage(chosenFileAsBlobDataUrl, {
width: size,
height: size,
}),
})),
(async () => ({
resourceName: 'android-windowSplashScreenAnimatedIcon.png',
blobDataUrl: await resizeImage(chosenFileAsBlobDataUrl, {
width: androidWindowSplashScreenAnimatedIconRecommendedSize,
height: androidWindowSplashScreenAnimatedIconRecommendedSize,
transparentBorderSize:
androidWindowSplashScreenAnimatedIconRecommendedSize / 6,
}),
}))(),
...iosSizes.map(async size => ({
resourceName: `ios-icon-${size}.png`,
blobDataUrl: await resizeImage(chosenFileAsBlobDataUrl, {
width: size,
height: size,
}),
})),
]);
// Important, we are responsible for deleting the resources that were given to us. results.forEach(({ resourceName, blobDataUrl }) => {
// Otherwise we have a memory leak. const metadata = JSON.stringify({
resources.forEach(resource => resource.delete()); extension: '.png',
// TODO: Check it works on Windows
localFilePath: `./assets/${resourceName}`,
});
if (!resourcesManager.hasResource(resourceName)) {
const imageResource = new gd.ImageResource();
imageResource.setFile(blobDataUrl);
imageResource.setName(resourceName);
imageResource.setMetadata(metadata);
const results = await Promise.all([ resourcesManager.addResource(imageResource);
...desktopSizes.map(size =>
resizeImage(image, path.join(projectPath, `desktop-icon-${size}.png`), { // Important, we are responsible for deleting the resources that we created
width: size, // Otherwise we have a memory leak, as calling addResource is making a copy of the resource.
height: size, imageResource.delete();
}) } else {
), resourcesManager.getResource(resourceName).setFile(blobDataUrl);
...androidSizes.map(size => resourcesManager.getResource(resourceName).setMetadata(metadata);
resizeImage(image, path.join(projectPath, `android-icon-${size}.png`), {
width: size,
height: size,
})
),
resizeImage(
image,
path.join(projectPath, 'android-windowSplashScreenAnimatedIcon.png'),
{
width: androidWindowSplashScreenAnimatedIconRecommendedSize,
height: androidWindowSplashScreenAnimatedIconRecommendedSize,
transparentBorderSize:
androidWindowSplashScreenAnimatedIconRecommendedSize / 6,
} }
), });
...iosSizes.map(size =>
resizeImage(image, path.join(projectPath, `ios-icon-${size}.png`), {
width: size,
height: size,
})
),
]);
if (results.indexOf(false) !== -1) { await resourceManagementProps.onFetchNewlyAddedResources();
// Make sure the resources are (re)loaded.
ResourcesLoader.burstUrlsCacheForResources(
project,
results.map(({ resourceName }) => resourceName)
);
setTimeout(() => {
this.setState({
desktopIconResourceNames: desktopSizes.map(
size => `desktop-icon-${size}.png`
),
androidIconResourceNames: androidSizes.map(
size => `android-icon-${size}.png`
),
androidWindowSplashScreenAnimatedIconResourceName:
'android-windowSplashScreenAnimatedIcon.png',
iosIconResourceNames: iosSizes.map(size => `ios-icon-${size}.png`),
});
}, 200 /* Let a bit of time so that image files can be found */);
} catch (e) {
showErrorBox({ showErrorBox({
message: 'Some icons could not be generated!', message: 'Some icons could not be generated.',
rawError: undefined, rawError: undefined,
errorId: 'icon-generation-error', errorId: 'icon-generation-error',
doNotReport: true, doNotReport: true,
}); });
return; } finally {
} // Reset input value so that if the user selects the same file again,
// the onChange callback is called.
// Add resources to the game if (this.inputRef) {
const allResourcesNames = [ this.inputRef.value = '';
...desktopSizes.map(size => `desktop-icon-${size}.png`),
...androidSizes.map(size => `android-icon-${size}.png`),
'android-windowSplashScreenAnimatedIcon.png',
...iosSizes.map(size => `ios-icon-${size}.png`),
];
allResourcesNames.forEach(resourceName => {
if (!resourcesManager.hasResource(resourceName)) {
const imageResource = new gd.ImageResource();
imageResource.setFile(resourceName);
imageResource.setName(resourceName);
resourcesManager.addResource(imageResource);
// Important, we are responsible for deleting the resources that we created
// Otherwise we have a memory leak, as calling addResource is making a copy of the resource.
imageResource.delete();
} else {
resourcesManager.getResource(resourceName).setFile(resourceName);
} }
}); }
// Make sure the resources are (re)loaded.
ResourcesLoader.burstUrlsCacheForResources(project, allResourcesNames);
setTimeout(() => {
this.setState({
desktopIconResourceNames: desktopSizes.map(
size => `desktop-icon-${size}.png`
),
androidIconResourceNames: androidSizes.map(
size => `android-icon-${size}.png`
),
androidWindowSplashScreenAnimatedIconResourceName:
'android-windowSplashScreenAnimatedIcon.png',
iosIconResourceNames: iosSizes.map(size => `ios-icon-${size}.png`),
});
}, 200 /* Let a bit of time so that image files can be found */);
}; };
onApply = () => { onApply = () => {
@@ -297,22 +285,20 @@ class PlatformSpecificAssetsDialog extends React.Component<Props, State> {
onApply={this.onApply} onApply={this.onApply}
> >
<ColumnStackLayout noMargin> <ColumnStackLayout noMargin>
<Line justifyContent="center" noMargin> <ResponsiveLineStackLayout
{!!path ? ( alignItems="center"
<RaisedButton noMargin
primary noColumnMargin
label={<Trans>Generate icons from a file</Trans>} >
onClick={this._generateFromFile} <Text noMargin>
/> <Trans>Generate icons from a file:</Trans>
) : ( </Text>
<Text> <input
<Trans> type="file"
Download GDevelop desktop version to generate the Android and onChange={this._generateFromFile}
iOS icons of your game. ref={_inputRef => (this.inputRef = _inputRef)}
</Trans> />
</Text> </ResponsiveLineStackLayout>
)}
</Line>
<Text size="sub-title"> <Text size="sub-title">
<Trans>gd.games thumbnail</Trans> <Trans>gd.games thumbnail</Trans>
</Text> </Text>