[particles] Add server upload/download
This is mostly cribbed from Skottie.
Bug: skia:
Change-Id: I6c6d0e8ef1bf02a998b8827c49c83fa0f70c9b45
Reviewed-on: https://skia-review.googlesource.com/c/buildbot/+/199249
Commit-Queue: Kevin Lubick <kjlubick@google.com>
Reviewed-by: Joe Gregorio <jcgregorio@google.com>
diff --git a/particles/Makefile b/particles/Makefile
index 6664e77..5913b41 100644
--- a/particles/Makefile
+++ b/particles/Makefile
@@ -2,6 +2,7 @@
npx webpack --mode=development
release: wasm_libs_tot
+ CGO_ENABLED=0 GOOS=linux go install -a ./go/particles
npx webpack --mode=production
./build_release
@@ -15,6 +16,9 @@
npm install
touch package-lock.json
+app: package-lock.json
+ go install ./go/particles
+
get_latest_skia:
docker pull gcr.io/skia-public/skia-wasm-release:prod
diff --git a/particles/go/particles/main.go b/particles/go/particles/main.go
new file mode 100644
index 0000000..c3455b8
--- /dev/null
+++ b/particles/go/particles/main.go
@@ -0,0 +1,258 @@
+package main
+
+import (
+ "context"
+ "crypto/md5"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "html/template"
+ "io"
+ "mime"
+ "net/http"
+ "path/filepath"
+ "runtime"
+ "strings"
+
+ "cloud.google.com/go/storage"
+ "github.com/gorilla/mux"
+ "go.skia.org/infra/go/allowed"
+ "go.skia.org/infra/go/auth"
+ "go.skia.org/infra/go/common"
+ "go.skia.org/infra/go/httputils"
+ "go.skia.org/infra/go/login"
+ "go.skia.org/infra/go/skerr"
+ "go.skia.org/infra/go/sklog"
+ "go.skia.org/infra/go/util"
+ "google.golang.org/api/option"
+)
+
+const (
+ // BUCKET is the Cloud Storage bucket we store files in.
+ BUCKET = "skparticles-renderer"
+ BUCKET_INTERNAL = "skparticles-renderer-internal"
+
+ MAX_FILENAME_SIZE = 5 * 1024
+ MAX_JSON_SIZE = 10 * 1024 * 1024
+)
+
+// flags
+var (
+ local = flag.Bool("local", false, "Running locally if true. As opposed to in production.")
+ lockedDown = flag.Bool("locked_down", false, "Restricted to only @google.com accounts.")
+ port = flag.String("port", ":8000", "HTTP service address (e.g., ':8000')")
+ promPort = flag.String("prom_port", ":20000", "Metrics service address (e.g., ':10110')")
+ resourcesDir = flag.String("resources_dir", "", "The directory to find templates, JS, and CSS files. If blank the current directory will be used.")
+)
+
+var (
+ invalidRequestErr = errors.New("")
+)
+
+// Server is the state of the server.
+type Server struct {
+ bucket *storage.BucketHandle
+ templates *template.Template
+}
+
+func New() (*Server, error) {
+ if *resourcesDir == "" {
+ _, filename, _, _ := runtime.Caller(0)
+ *resourcesDir = filepath.Join(filepath.Dir(filename), "../../dist")
+ }
+
+ // Need to set the mime-type for wasm files so streaming compile works.
+ if err := mime.AddExtensionType(".wasm", "application/wasm"); err != nil {
+ sklog.Fatal(err)
+ }
+
+ ts, err := auth.NewDefaultTokenSource(*local, storage.ScopeFullControl)
+ if err != nil {
+ return nil, fmt.Errorf("Failed to get token source: %s", err)
+ }
+ client := httputils.DefaultClientConfig().WithTokenSource(ts).With2xxOnly().Client()
+ storageClient, err := storage.NewClient(context.Background(), option.WithHTTPClient(client))
+ if err != nil {
+ return nil, fmt.Errorf("Problem creating storage client: %s", err)
+ }
+
+ if *lockedDown {
+ allow := allowed.NewAllowedFromList([]string{"google.com"})
+ login.InitWithAllow(*port, *local, nil, nil, allow)
+ }
+
+ bucket := BUCKET
+ if *lockedDown {
+ bucket = BUCKET_INTERNAL
+ }
+
+ srv := &Server{
+ bucket: storageClient.Bucket(bucket),
+ }
+ srv.loadTemplates()
+ return srv, nil
+}
+
+func (srv *Server) loadTemplates() {
+ srv.templates = template.Must(template.New("").Delims("{%", "%}").ParseFiles(
+ filepath.Join(*resourcesDir, "index.html"),
+ ))
+}
+func (srv *Server) templateHandler(filename string) func(http.ResponseWriter, *http.Request) {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html")
+ if *local {
+ srv.loadTemplates()
+ }
+ if err := srv.templates.ExecuteTemplate(w, filename, nil); err != nil {
+ sklog.Errorf("Failed to expand template %s: %s", filename, err)
+ }
+ }
+}
+func (srv *Server) jsonHandler(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ w.Header().Set("Access-Control-Allow-Origin", "*")
+ hash := mux.Vars(r)["hash"]
+ path := strings.Join([]string{hash, "input.json"}, "/")
+ reader, err := srv.bucket.Object(path).NewReader(r.Context())
+ if err != nil {
+ sklog.Warningf("Can't load JSON file %s from GCS: %s", path, err)
+ w.WriteHeader(http.StatusNotFound)
+ return
+ }
+ if _, err = io.Copy(w, reader); err != nil {
+ httputils.ReportError(w, r, err, "Failed to write JSON file.")
+ return
+ }
+}
+
+type UploadRequest struct {
+ ParticlesJSON interface{} `json:"json"` // the parsed JSON
+ Filename string `json:"filename"`
+}
+
+type UploadResponse struct {
+ Hash string `json:"hash"`
+}
+
+func (srv *Server) uploadHandler(w http.ResponseWriter, r *http.Request) {
+ ctx := r.Context()
+ // Extract json file.
+ defer util.Close(r.Body)
+ var req UploadRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ httputils.ReportError(w, r, err, "Error decoding JSON.")
+ return
+ }
+ // Check for maliciously sized input on any field we upload to GCS
+ if len(req.Filename) > MAX_FILENAME_SIZE {
+ httputils.ReportError(w, r, nil, "Input file(s) too big")
+ return
+ }
+
+ // Calculate md5 of UploadRequest (json contents and file name)
+ h := md5.New()
+ b, err := json.Marshal(req)
+ if err != nil {
+ httputils.ReportError(w, r, err, "Can't re-encode request.")
+ return
+ }
+ if _, err = h.Write(b); err != nil {
+ httputils.ReportError(w, r, err, "Failed calculating hash.")
+ return
+ }
+ hash := fmt.Sprintf("%x", h.Sum(nil))
+
+ if strings.HasSuffix(req.Filename, ".json") {
+ if err := srv.createFromJSON(&req, hash, ctx); err != nil {
+ httputils.ReportError(w, r, err, "Failed handing input of JSON.")
+ return
+ }
+ } else {
+ w.WriteHeader(http.StatusBadRequest)
+ msg := "Only .json files allowed"
+ if _, err := w.Write([]byte(msg)); err != nil {
+ sklog.Errorf("Failed to write error response: %s", err)
+ }
+ return
+ }
+
+ resp := UploadResponse{
+ Hash: hash,
+ }
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ sklog.Errorf("Failed to write response: %s", err)
+ }
+}
+
+func (srv *Server) createFromJSON(req *UploadRequest, hash string, ctx context.Context) error {
+ b, err := json.Marshal(req.ParticlesJSON)
+ if err != nil {
+ return skerr.Fmt("Can't re-encode json file: %s", err)
+ }
+ if len(b) > MAX_JSON_SIZE {
+ return skerr.Fmt("Particles JSON is too big (%d bytes): %s", len(b), err)
+ }
+
+ return srv.uploadState(req, hash, ctx)
+}
+
+func (srv *Server) uploadState(req *UploadRequest, hash string, ctx context.Context) error {
+ // Write JSON file, containing the state (filename, json, etc)
+ bytesToUpload, err := json.Marshal(req)
+ if err != nil {
+ return skerr.Fmt("Can't re-encode request: %s", err)
+ }
+
+ path := strings.Join([]string{hash, "input.json"}, "/")
+ obj := srv.bucket.Object(path)
+ wr := obj.NewWriter(ctx)
+ wr.ObjectAttrs.ContentEncoding = "application/json"
+ if _, err := wr.Write(bytesToUpload); err != nil {
+ return skerr.Fmt("Failed writing JSON to GCS: %s", err)
+ }
+ if err := wr.Close(); err != nil {
+ return skerr.Fmt("Failed writing JSON to GCS on close: %s", err)
+ }
+ return nil
+}
+
+func main() {
+ common.InitWithMust(
+ "particles",
+ common.PrometheusOpt(promPort),
+ common.MetricsLoggingOpt(),
+ )
+
+ if *lockedDown && *local {
+ sklog.Fatalf("Can't be run as both --locked_down and --local.")
+ }
+
+ srv, err := New()
+ if err != nil {
+ sklog.Fatalf("Failed to start: %s", err)
+ }
+
+ r := mux.NewRouter()
+ r.HandleFunc("/{hash:[0-9A-Za-z]*}", srv.templateHandler("index.html")).Methods("GET")
+ r.HandleFunc("/_/j/{hash:[0-9A-Za-z]+}", srv.jsonHandler).Methods("GET")
+ r.HandleFunc("/_/upload", srv.uploadHandler).Methods("POST")
+
+ r.PathPrefix("/static/").Handler(http.StripPrefix("/static/", http.HandlerFunc(httputils.CorsHandler(httputils.MakeResourceHandler(*resourcesDir))))).Methods("GET")
+
+ // TODO(jcgregorio) Implement CSRF.
+ h := httputils.LoggingGzipRequestResponse(r)
+ if !*local {
+ if *lockedDown {
+ h = login.RestrictViewer(h)
+ h = login.ForceAuth(h, login.DEFAULT_REDIRECT_URL)
+ }
+ h = httputils.HealthzAndHTTPS(h)
+ }
+
+ http.Handle("/", h)
+ sklog.Infoln("Ready to serve.")
+ sklog.Fatal(http.ListenAndServe(*port, nil))
+}
diff --git a/particles/modules/particles-config-sk/index.js b/particles/modules/particles-config-sk/index.js
new file mode 100644
index 0000000..5f0035f
--- /dev/null
+++ b/particles/modules/particles-config-sk/index.js
@@ -0,0 +1,2 @@
+import './particles-config-sk.js'
+import './particles-config-sk.scss'
diff --git a/particles/modules/particles-config-sk/particles-config-sk.js b/particles/modules/particles-config-sk/particles-config-sk.js
new file mode 100644
index 0000000..c41e10d
--- /dev/null
+++ b/particles/modules/particles-config-sk/particles-config-sk.js
@@ -0,0 +1,159 @@
+/**
+ * @module particles-config-sk
+ * @description <h2><code>particles-config-sk</code></h2>
+ *
+ * <p>
+ * A dialog for configuring how to render a Particles JSON file.
+ * </p>
+ *
+ * <p>
+ * The form of the 'state' property looks like a serialized UploadRequest:
+ * </p>
+ * <pre>
+ * {
+ * filename: 'foo.json',
+ * json: {},
+ * }
+ * <pre>
+ *
+ * @evt particles-json-selected - This event is generated when the user presses Go.
+ * The updated state, width, and height is available in the event detail.
+ * There is also an indication if the particles file was changed.
+ *
+ * @evt cancelled - This event is generated when the user presses Cancel.
+ *
+ */
+import 'elements-sk/styles/buttons'
+import { errorMessage } from 'elements-sk/errorMessage'
+import { html, render } from 'lit-html'
+import { $$ } from 'common-sk/modules/dom'
+
+const DEFAULT_SIZE = 600;
+
+const cancelButton = (ele) => ele._hasCancel() ? html`<button id=cancel @click=${ele._cancel}>Cancel</button>` : '';
+
+const template = (ele) => html`
+ <label class=file>Particles file to upload
+ <input type=file name=file id=file @change=${ele._onFileChange}/>
+ </label>
+ <div class="filename ${ele._state.filename ? '' : 'empty'}">
+ ${ele._state.filename ? ele._state.filename : 'No file selected.'}
+ </div>
+ <label class=number>
+ <input type=number id=width .value=${ele._width} required /> Width (px)
+ </label>
+ <label class=number>
+ <input type=number id=height .value=${ele._height} required /> Height (px)
+ </label>
+ <div id=dialog-buttons>
+ ${cancelButton(ele)}
+ <button class=action ?disabled=${ele._readyToGo()} @click=${ele._go}>Go</button>
+ </div>
+`;
+
+class ParticlesConfigSk extends HTMLElement {
+ constructor() {
+ super();
+ this._state = {
+ filename: '',
+ json: null
+ };
+ this._width = DEFAULT_SIZE;
+ this._height = DEFAULT_SIZE;
+ this._fileChanged = false;
+ this._starting_state = Object.assign({}, this._state);
+ }
+
+ connectedCallback() {
+ this._render();
+ this.addEventListener('input', this._inputEvent);
+ }
+
+ disconnectedCallback() {
+ this.removeEventListener('input', this._inputEvent);
+ }
+
+ /** @prop height {Number} Selected height for animation. */
+ get height() { return this._height; }
+ set height(val) {
+ this._height= +val;
+ this._render();
+ }
+
+ /** @prop state {string} Object that describes the state of the config dialog. */
+ get state() { return this._state; }
+ set state(val) {
+ this._state = Object.assign({}, val);
+ this._starting_state = Object.assign({}, this._state);
+ this._render();
+ }
+
+ /** @prop width {Number} Selected width for animation. */
+ get width() { return this._width; }
+ set width(val) {
+ this._width = +val;
+ this._render();
+ }
+
+ _hasCancel() {
+ return !!this._starting_state.json;
+ }
+
+ _readyToGo() {
+ return !this._state.json;
+ }
+
+ _onFileChange(e) {
+ this._fileChanged = true;
+ let reader = new FileReader();
+ reader.addEventListener('load', () => {
+ let parsed = {};
+ try {
+ parsed = JSON.parse(reader.result);
+ }
+ catch(error) {
+ errorMessage(`Not a valid JSON file: ${error}`);
+ return;
+ }
+ this._state.json = parsed;
+ this._state.filename = e.target.files[0].name;
+ this._width = parsed.w || DEFAULT_SIZE;
+ this._height = parsed.h || DEFAULT_SIZE;
+ this._render();
+ });
+ reader.addEventListener('error', () => {
+ errorMessage('Failed to load.');
+ });
+ reader.readAsText(e.target.files[0]);
+ }
+
+ _updateState() {
+ this._width = +$$('#width', this).value;
+ this._height = +$$('#height', this).value;
+ }
+
+ _go() {
+ this._updateState();
+ this.dispatchEvent(new CustomEvent('particles-json-selected', { detail: {
+ 'state' : this._state,
+ 'fileChanged': this._fileChanged,
+ 'width' : this._width,
+ 'height': this._height,
+ }, bubbles: true }));
+ }
+
+ _cancel() {
+ this.dispatchEvent(new CustomEvent('cancelled', { bubbles: true }));
+ }
+
+ _inputEvent() {
+ this._updateState();
+ this._render();
+ }
+
+ _render() {
+ render(template(this), this, {eventContext: this});
+ }
+};
+
+window.customElements.define('particles-config-sk', ParticlesConfigSk);
\ No newline at end of file
diff --git a/particles/modules/particles-config-sk/particles-config-sk.scss b/particles/modules/particles-config-sk/particles-config-sk.scss
new file mode 100644
index 0000000..ff38791
--- /dev/null
+++ b/particles/modules/particles-config-sk/particles-config-sk.scss
@@ -0,0 +1,58 @@
+@import url(~elements-sk/colors.css);
+
+particles-config-sk {
+ display: block;
+
+ // TODO(jcgregorio) Add a .button-like class to elements-sk to avoid the copy-paste.
+ label.file {
+ min-width: 5.14em;
+ background-color: var(--white);
+ color: var(--blue);
+ fill: var(--blue);
+ text-align: center;
+ outline: none;
+ border-radius: 4px;
+ padding: 0.6em 1.2em;
+ border: solid var(--light-gray) 1px;
+ margin: 0.6em;
+ height: 3em;
+ transition: box-shadow 0.1s cubic-bezier(0.4, 0, 0.2, 1);
+ box-shadow: 2px 2px 8px 0 rgba(0, 0, 0, 0.6);
+ }
+
+ input[type=file] {
+ visibility: hidden;
+ width: 0;
+ }
+
+ .filename {
+ border: solid var(--light-gray) 1px;
+ padding: 0.4em;
+ margin: 0.6em;
+ display: inline-block;
+ font-style: inherit;
+ font-weight: bold;
+ }
+
+ .empty {
+ font-style: italic;
+ font-weight: inherit;
+ }
+
+ input[type=number] {
+ margin-right: 0.6em;
+ padding: 0.3em;
+ border: solid var(--light-gray) 1px;
+ }
+
+ label.number {
+ display: block;
+ margin: 0.6em;
+ }
+
+ #dialog-buttons {
+ width: 21em;
+ margin-top: 1em;
+ text-align: right;
+ }
+}
diff --git a/particles/modules/particles-sk/particles-sk-demo.js b/particles/modules/particles-sk/particles-sk-demo.js
index e9919ee..2449e2e 100644
--- a/particles/modules/particles-sk/particles-sk-demo.js
+++ b/particles/modules/particles-sk/particles-sk-demo.js
@@ -1,6 +1,27 @@
import './index.js'
import './particles-sk-demo.css'
+import { spiral } from './test_data.js'
+
const fetchMock = require('fetch-mock');
-// TODO
\ No newline at end of file
+let state = {
+ filename: 'spiral.json',
+ json: spiral,
+}
+fetchMock.get('glob:/_/j/*', {
+ status: 200,
+ body: JSON.stringify(state),
+ headers: {'Content-Type':'application/json'},
+});
+
+fetchMock.post('glob:/_/upload', {
+ status: 200,
+ body: JSON.stringify({
+ hash: 'MOCK_UPLOADED',
+ }),
+ headers: {'Content-Type':'application/json'},
+});
+
+// Pass-through CanvasKit.
+fetchMock.get('glob:*.wasm', fetchMock.realFetch.bind(window));
\ No newline at end of file
diff --git a/particles/modules/particles-sk/particles-sk.js b/particles/modules/particles-sk/particles-sk.js
index 662ee41..eb2dcae 100644
--- a/particles/modules/particles-sk/particles-sk.js
+++ b/particles/modules/particles-sk/particles-sk.js
@@ -8,6 +8,7 @@
*
*/
import '../particles-player-sk'
+import '../particles-config-sk'
import 'elements-sk/checkbox-sk'
import 'elements-sk/error-toast-sk'
import 'elements-sk/styles/buttons'
@@ -18,9 +19,6 @@
import { jsonOrThrow } from 'common-sk/modules/jsonOrThrow'
import { stateReflector } from 'common-sk/modules/stateReflector'
-// FIXME(kjlubick): remove this placeholder
-import { spiral } from './test_data.js'
-
const JSONEditor = require('jsoneditor/dist/jsoneditor-minimalist.js');
const DIALOG_MODE = 1;
@@ -32,7 +30,7 @@
const SCRUBBER_RANGE = 1000;
const displayDialog = (ele) => html`
-<div> TODO config / dialog </div>
+<particles-config-sk .state=${ele._state} .width=${ele._width} .height=${ele._height}></particles-config-sk>
`;
const particlesPlayer = (ele) => html`
@@ -53,7 +51,15 @@
</section>`;
}
+const gallery = (ele) => html`
+Check out these examples ==>
+<a href="/a879da270cf25c70600810cb42ed78ff">spiral</a>
+<a href="/1afc7fa7bc923aad06f538982ddf5ba8">swirl</a>
+<a href="/7c132e60cc25fd6893998bd797eafb65">text</a>
+`;
+
const displayLoaded = (ele) => html`
+${gallery(ele)}
<button class=edit-config @click=${ ele._startEdit}>
${ele._state.filename} ${ele._width}x${ele._height} ...
</button>
@@ -237,26 +243,39 @@
this._playing = !this._playing;
}
+ _recoverFromError(msg) {
+ errorMessage(msg);
+ console.error(msg);
+ window.history.pushState(null, '', '/');
+ this._ui = DIALOG_MODE;
+ this.render();
+ }
+
_reflectFromURL() {
// Check URL.
let match = window.location.pathname.match(/\/([a-zA-Z0-9]+)/);
if (!match) {
// Make this the hash of the particles file you want to play on startup.
- this._hash = '1112d01d28a776d777cebcd0632da15b'; // spiral.json
+ this._hash = 'a879da270cf25c70600810cb42ed78ff'; // spiral.json
} else {
this._hash = match[1];
}
this._ui = LOADING_MODE;
this.render();
- // TODO(kjlubick) Actually make a fetch request
- this._state.json = spiral;
- this._state.filename = 'spiral.json';
- this._ui = LOADED_MODE;
- this.render();
- this._initializePlayer();
- // Force start playing
- this._playing = false;
- this._playpause();
+ // Run this on the next micro-task to allow mocks to be set up if needed.
+ setTimeout(() => {
+ fetch(`/_/j/${this._hash}`, {
+ credentials: 'include',
+ }).then(jsonOrThrow).then(json => {
+ this._state = json;
+ this._ui = LOADED_MODE;
+ this.render();
+ this._initializePlayer();
+ // Force start playing
+ this._playing = false;
+ this._playpause();
+ }).catch((msg) => this._recoverFromError(msg));
+ });
}
render() {
@@ -344,7 +363,21 @@
this._editor = null;
// Clean up the old animation and other wasm objects
this.render();
- console.log('should upload JSON')
+ fetch('/_/upload', {
+ credentials: 'include',
+ body: JSON.stringify(this._state),
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ method: 'POST',
+ }).then(jsonOrThrow).then((json) => {
+ // Should return with the hash
+ this._ui = LOADED_MODE;
+ this._hash = json.hash;
+ window.history.pushState(null, '', '/' + this._hash);
+ this._stateChanged();
+ this.render();
+ }).catch((msg) => this._recoverFromError(msg));
this._ui = LOADED_MODE;
// Start drawing right away, no need to wait for
diff --git a/particles/pages/index.html b/particles/pages/index.html
new file mode 100644
index 0000000..35d40a4
--- /dev/null
+++ b/particles/pages/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <title>Particles Playground for Skia</title>
+ <meta charset="utf-8" />
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <link rel="stylesheet" href="/static/jsoneditor.css">
+</head>
+<body>
+ <particles-sk></particles-sk>
+</body>
+</html>
diff --git a/particles/pages/index.js b/particles/pages/index.js
new file mode 100644
index 0000000..0af5fbc
--- /dev/null
+++ b/particles/pages/index.js
@@ -0,0 +1,3 @@
+import '../node_modules/@webcomponents/custom-elements/custom-elements.min.js'
+import './index.scss'
+import '../modules/particles-sk'
diff --git a/particles/pages/index.scss b/particles/pages/index.scss
new file mode 100644
index 0000000..2b84b26
--- /dev/null
+++ b/particles/pages/index.scss
@@ -0,0 +1,6 @@
+body {
+ margin: 0;
+ padding: 0;
+ transition: none;
+ font-family: sans-serif;
+}
diff --git a/skottie/go/skottie/main.go b/skottie/go/skottie/main.go
index c369766..cef5b0b 100644
--- a/skottie/go/skottie/main.go
+++ b/skottie/go/skottie/main.go
@@ -480,8 +480,8 @@
r := mux.NewRouter()
r.HandleFunc("/drive", srv.templateHandler("drive.html"))
r.HandleFunc("/google99d1f93c6755806b.html", srv.verificationHandler)
- r.HandleFunc("/{hash:[0-9A-Za-z]*}", srv.templateHandler("index.html"))
- r.HandleFunc("/e/{hash:[0-9A-Za-z]*}", srv.templateHandler("embed.html"))
+ r.HandleFunc("/{hash:[0-9A-Za-z]*}", srv.templateHandler("index.html")).Methods("GET")
+ r.HandleFunc("/e/{hash:[0-9A-Za-z]*}", srv.templateHandler("embed.html")).Methods("GET")
r.HandleFunc("/_/j/{hash:[0-9A-Za-z]+}", srv.jsonHandler).Methods("GET")
r.HandleFunc(`/_/a/{hash:[0-9A-Za-z]+}/{name:[A-Za-z0-9\._\-]+}`, srv.assetsHandler).Methods("GET")
diff --git a/skottie/modules/skottie-config-sk/skottie-config-sk.js b/skottie/modules/skottie-config-sk/skottie-config-sk.js
index ce143a6..b2c61bf 100644
--- a/skottie/modules/skottie-config-sk/skottie-config-sk.js
+++ b/skottie/modules/skottie-config-sk/skottie-config-sk.js
@@ -1,5 +1,5 @@
/**
- * @module /skottie-config-sk
+ * @module skottie-config-sk
* @description <h2><code>skottie-config-sk</code></h2>
*
* <p>
@@ -13,9 +13,8 @@
* {
* filename: 'foo.json',
* lottie: {},
- * width: 256,
- * height: 256,
- * fps: 30,
+ * assetsZip: 'data:application/zip;base64,...'
+ * assetsFileName: 'assets.zip'
* }
* <pre>
*
diff --git a/skottie/modules/skottie-sk/skottie-sk.js b/skottie/modules/skottie-sk/skottie-sk.js
index 8ca675c..02a6111 100644
--- a/skottie/modules/skottie-sk/skottie-sk.js
+++ b/skottie/modules/skottie-sk/skottie-sk.js
@@ -449,6 +449,14 @@
this._playing = !this._playing;
}
+ _recoverFromError(msg) {
+ errorMessage(msg);
+ console.error(msg);
+ window.history.pushState(null, '', '/');
+ this._ui = DIALOG_MODE;
+ this.render();
+ }
+
_reflectFromURL() {
// Check URL.
let match = window.location.pathname.match(/\/([a-zA-Z0-9]+)/);
@@ -477,13 +485,7 @@
this._ui = LOADED_MODE;
this._loadAssetsAndRender();
this._rewind();
- }).catch((msg) => {
- console.error(msg);
- errorMessage(msg);
- window.history.pushState(null, '', '/');
- this._ui = DIALOG_MODE;
- this.render();
- });
+ }).catch((msg) => this._recoverFromError(msg));
});
}
@@ -684,12 +686,7 @@
this._rewind();
}
this.render();
- }).catch(msg => {
- errorMessage(msg);
- window.history.pushState(null, '', '/');
- this._ui = DIALOG_MODE;
- this.render();
- });
+ }).catch((msg) => this._recoverFromError(msg));
if (!this._state.assetsZip) {
this._ui = LOADED_MODE;