[shaders] Add the ability to use images from other sites.
Bug: skia:11272
Change-Id: Ic80f76f8ae67c69253032c0a175ccf122377157e
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/374476
Reviewed-by: Joe Gregorio <jcgregorio@google.com>
diff --git a/shaders/modules/shadernode/index.ts b/shaders/modules/shadernode/index.ts
index b511b06..7f39dff 100644
--- a/shaders/modules/shadernode/index.ts
+++ b/shaders/modules/shadernode/index.ts
@@ -130,6 +130,9 @@
*/
private _currentUserUniformValues: number[] = [];
+ /** The current image being displayed, even if a blob: url. */
+ private currentImageURL: string = '';
+
private _numPredefinedUniformValues: number = 0;
constructor(canvasKit: CanvasKit) {
@@ -161,14 +164,7 @@
this.body = scrapBody;
this._shaderCode = this.body.Body;
this.currentUserUniformValues = this.body.SKSLMetaData?.Uniforms || [];
-
- const imageURL = this.body.SKSLMetaData?.ImageURL || defaultImageURL;
- this.promiseOnImageLoaded(imageURL).then((imageElement) => {
- this.inputImageShaderFromCanvasImageSource(imageElement);
- if (imageLoadedCallback) {
- imageLoadedCallback();
- }
- }).catch(errorMessage);
+ this.setCurrentImageURL(this.body?.SKSLMetaData?.ImageURL || defaultImageURL, imageLoadedCallback);
this.compile();
}
@@ -182,6 +178,43 @@
}
/**
+ * Don't save or display image URLs that are blob:// or file:// urls.
+ */
+ getSafeImageURL(): string {
+ if (!this.currentImageURLIsSafe()) {
+ return this.body?.SKSLMetaData?.ImageURL || defaultImageURL;
+ }
+ return this.getCurrentImageURL();
+ }
+
+ /** The current image being used. Note that this could be a blob: URL. */
+ getCurrentImageURL(): string {
+ return this.currentImageURL;
+ }
+
+ /**
+ * Sets the current image to use. Note that if the image fails to load then
+ * the current image URL will be set to the empty string.
+ */
+ setCurrentImageURL(val: string, imageLoadedCallback: callback | null = null): void {
+ this.currentImageURL = val;
+
+ this.promiseOnImageLoaded(this.currentImageURL).then((imageElement) => {
+ this.inputImageShaderFromCanvasImageSource(imageElement);
+ if (imageLoadedCallback) {
+ imageLoadedCallback();
+ }
+ }).catch(() => {
+ errorMessage(`Failed to load image: ${this.currentImageURL}. Falling back to an empty image.`);
+ this.currentImageURL = '';
+ this.inputImageShaderFromCanvasImageSource(new Image(DEFAULT_SIZE, DEFAULT_SIZE));
+ if (imageLoadedCallback) {
+ imageLoadedCallback();
+ }
+ });
+ }
+
+ /**
* Saves the scrap to the backend returning a Promise that resolves to the
* scrap id that it was stored at, or reject on an error.
*/
@@ -191,10 +224,7 @@
Type: 'sksl',
SKSLMetaData: {
Uniforms: this._currentUserUniformValues,
- // TODO(jcgregorio) Remember once we start saving ImageURLs that we
- // need to to remove ImageURLs that aren't relative or that don't
- // start with http[s]://, e.g. don't store blob:// or file:// urls.
- ImageURL: '',
+ ImageURL: this.getSafeImageURL(),
Children: [],
},
};
@@ -290,7 +320,7 @@
/** Returns true if this node or any child node needs to be saved. */
needsSave(): boolean {
- return (this._shaderCode !== this.body!.Body) || this.userUniformValuesHaveBeenEdited();
+ return (this._shaderCode !== this.body!.Body) || this.userUniformValuesHaveBeenEdited() || this.imageURLHasChanged();
}
/** Returns the number of uniforms in the effect. */
@@ -388,6 +418,26 @@
return false;
}
+ private currentImageURLIsSafe() {
+ const url = new URL(this.currentImageURL, window.location.toString());
+ if (url.protocol === 'https:' || url.protocol === 'http:') {
+ return true;
+ }
+ return false;
+ }
+
+ private imageURLHasChanged(): boolean {
+ if (!this.currentImageURLIsSafe()) {
+ return false;
+ }
+ const current = new URL(this.currentImageURL, window.location.toString());
+ const saved = new URL(this.body?.SKSLMetaData?.ImageURL || '', window.location.toString());
+ if (current.toString() !== saved.toString()) {
+ return true;
+ }
+ return false;
+ }
+
private findUniform(name: string): Uniform | null {
for (let i = 0; i < this.uniforms.length; i++) {
if (name === this.uniforms[i].name) {
@@ -400,6 +450,7 @@
private promiseOnImageLoaded(url: string): Promise<HTMLImageElement> {
return new Promise<HTMLImageElement>((resolve, reject) => {
const ele = new Image();
+ ele.crossOrigin = 'anonymous';
ele.src = url;
if (ele.complete) {
resolve(ele);
diff --git a/shaders/modules/shadernode/index_test.ts b/shaders/modules/shadernode/index_test.ts
index 86d14f2..03cf702 100644
--- a/shaders/modules/shadernode/index_test.ts
+++ b/shaders/modules/shadernode/index_test.ts
@@ -104,7 +104,7 @@
`,
SKSLMetaData: {
Children: [],
- ImageURL: '',
+ ImageURL: '/dist/mandrill.png',
Uniforms: startingUniformValues,
},
});
@@ -112,7 +112,7 @@
// Changing the code means we need to save.
const originalCode = node.shaderCode;
- assert.isFalse(node.needsSave());
+ assert.isFalse(node.needsSave(), 'No need to save at the start.');
node.shaderCode += '\n';
assert.isTrue(node.needsSave(), 'Needs save if code changed.');
node.shaderCode = originalCode;
@@ -161,4 +161,18 @@
const node = await createShaderNode();
assert.isNotNull(node.inputImageElement);
});
+
+ it('protects against unsafe URLs', async () => {
+ const node = await createShaderNode();
+ node['currentImageURL'] = 'data:foo';
+ assert.equal(node.getCurrentImageURL(), 'data:foo');
+ assert.equal(node.getSafeImageURL(), '/dist/mandrill.png');
+ });
+
+ it('reverts to empty image URL if image fails to load.', async () => {
+ const node = await createShaderNode();
+ node.setCurrentImageURL('/dist/some-unknown-image.png', () => {
+ assert.equal(node.getCurrentImageURL(), '');
+ });
+ });
});
diff --git a/shaders/modules/shaders-app-sk/shaders-app-sk.scss b/shaders/modules/shaders-app-sk/shaders-app-sk.scss
index 0df4dd0..067a029 100644
--- a/shaders/modules/shaders-app-sk/shaders-app-sk.scss
+++ b/shaders/modules/shaders-app-sk/shaders-app-sk.scss
@@ -132,7 +132,7 @@
#imageSources {
display: flex;
- align-items: center;
+ align-items: flex-start;
margin: 8px;
figure {
@@ -170,6 +170,55 @@
}
}
+ details#image_edit {
+ margin: 0 8px 8px 0;
+
+ summary {
+ list-style: none;
+
+ edit-icon-sk {
+ display: inline-block;
+
+ svg {
+ width: 16px;
+ height: 16px;
+ }
+ }
+ }
+ }
+
+ details#image_edit > summary::-webkit-details-marker {
+ display: none;
+ }
+
+ details#image_edit[open] {
+ summary {
+ margin: 0 8px 8px 0;
+ }
+
+ #image_edit_dialog {
+ border: solid var(--surface-2dp) 1px;
+ padding: 8px;
+
+ > * {
+ margin: 0 8px 8px 0;
+ }
+ }
+
+ label {
+ display: block;
+ margin: 0 8px 8px 0;
+ }
+
+ button {
+ margin: 0 0 0 4px;
+ }
+
+ input[type='url'] {
+ padding: 4px;
+ }
+ }
+
.CodeMirror {
height: auto;
@@ -178,31 +227,4 @@
font-size: 13px;
}
}
-
- /* Now set the non-standard styles. Unfortunately the way webpack/cssmin is
- configured currently it will strip the following rules from the output so we
- turn the autoprefixer off.
- */
-
- /* autoprefixer: off */
-
- #image_upload {
- border: none;
- }
-
- #image_upload::-webkit-file-upload-button {
- background-color: var(--surface-1dp);
- color: var(--on-surface);
- fill: var(--on-surface);
- outline: none;
- border: solid var(--on-surface) 1px;
- }
-
- #image_upload::file-selector-button {
- background-color: var(--surface-1dp);
- color: var(--on-surface);
- fill: var(--on-surface);
- outline: none;
- border: solid var(--on-surface) 1px;
- }
}
diff --git a/shaders/modules/shaders-app-sk/shaders-app-sk.ts b/shaders/modules/shaders-app-sk/shaders-app-sk.ts
index da7dad9..77235f0 100644
--- a/shaders/modules/shaders-app-sk/shaders-app-sk.ts
+++ b/shaders/modules/shaders-app-sk/shaders-app-sk.ts
@@ -22,6 +22,7 @@
import 'elements-sk/error-toast-sk';
import 'elements-sk/styles/buttons';
import 'elements-sk/styles/select';
+import 'elements-sk/icon/edit-icon-sk';
import '../../../infra-sk/modules/theme-chooser-sk';
import { SKIA_VERSION } from '../../build/version';
import { ElementSk } from '../../../infra-sk/modules/ElementSk/ElementSk';
@@ -226,7 +227,14 @@
</header>
<main>
<div>
- <p id=examples @click=${ele.fastLoad}>Examples: <a href="/?id=@inputs">Uniforms</a> <a href="/?id=@iResolution">iResolution</a> <a href="/?id=@iTime">iTime</a> <a href="/?id=@iMouse">iMouse</a> <a href="/?id=@iImage">iImage</a></p>
+ <p id=examples @click=${ele.fastLoad}>
+ Examples:
+ <a href="/?id=@inputs">Uniforms</a>
+ <a href="/?id=@iResolution">iResolution</a>
+ <a href="/?id=@iTime">iTime</a>
+ <a href="/?id=@iMouse">iMouse</a>
+ <a href="/?id=@iImage">iImage</a>
+ </p>
<canvas
id="player"
width=${ele.width}
@@ -243,9 +251,26 @@
<figure>
${ele.shaderNode?.inputImageElement}
<figcaption>iImage1</figcaption>
- <input @change=${ele.imageUploaded} type="file" id=image_upload accept="image/*">
</figure>
- </div>
+ <details id=image_edit>
+ <summary><edit-icon-sk></edit-icon-sk></summary>
+ <div id=image_edit_dialog>
+ <label for=image_url>
+ Change the URL used for the source image.
+ </label>
+ <div>
+ <input type=url id=image_url placeholder="URL of image to use." .value="${ele.shaderNode?.getSafeImageURL() || ''}">
+ <button @click=${ele.imageURLChanged}>Use</button>
+ </div>
+ <label for=image_upload>
+ Or upload an image to <em>temporarily</em> try as a source for the shader. Uploaded images are not saved.
+ </label>
+ <div>
+ <input @change=${ele.imageUploaded} type=file id=image_upload accept="image/*">
+ </div>
+ </div>
+ </details>
+ </div>
</details>
<div id="codeEditor"></div>
<div ?hidden=${!ele.shaderNode?.compileErrorMessage} id="compileErrors">
@@ -500,19 +525,15 @@
return;
}
const file = input.files.item(0)!;
+ this.setCurrentImageURL(URL.createObjectURL(file));
+ }
- // Update the current scrap to set the ImageURL to the uploaded file.
- const scrap = this.shaderNode!.getScrap();
- const oldURL = scrap.SKSLMetaData?.ImageURL || '';
-
- // Release unused memory.
- if (oldURL.startsWith('blob:')) {
- URL.revokeObjectURL(oldURL);
+ private imageURLChanged(): void {
+ const input = $$<HTMLInputElement>('#image_url', this)!;
+ if (!input.value) {
+ return;
}
-
- // Display new image.
- scrap.SKSLMetaData!.ImageURL = URL.createObjectURL(file);
- this.shaderNode!.setScrap(scrap, () => this._render());
+ this.setCurrentImageURL(input.value);
}
private codeChange() {
@@ -536,6 +557,17 @@
this.stateChanged!();
this.loadShaderIfNecessary();
}
+
+ private setCurrentImageURL(url: string): void {
+ const oldURL = this.shaderNode!.getCurrentImageURL();
+
+ // Release unused memory.
+ if (oldURL.startsWith('blob:')) {
+ URL.revokeObjectURL(oldURL);
+ }
+
+ this.shaderNode!.setCurrentImageURL(url, () => this._render());
+ }
}
define('shaders-app-sk', ShadersAppSk);