diff --git a/cmd/serve.go b/cmd/serve.go index b2bf0c71eb..79106f3a3e 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -10,6 +10,7 @@ var ( host string port int watch bool + serveGif bool ) func init() { @@ -18,6 +19,7 @@ func init() { ServeCmd.Flags().BoolVarP(&watch, "watch", "w", true, "Reload scripts on change. Does not recurse sub-directories.") ServeCmd.Flags().IntVarP(&maxDuration, "max_duration", "d", 15000, "Maximum allowed animation duration (ms)") ServeCmd.Flags().IntVarP(&timeout, "timeout", "", 30000, "Timeout for execution (ms)") + ServeCmd.Flags().BoolVarP(&serveGif, "gif", "", false, "Generate GIF instead of WebP") } var ServeCmd = &cobra.Command{ @@ -33,7 +35,7 @@ containing multiple Starlark files and resources.`, } func serve(cmd *cobra.Command, args []string) error { - s, err := server.NewServer(host, port, watch, args[0], maxDuration, timeout) + s, err := server.NewServer(host, port, watch, args[0], maxDuration, timeout, serveGif) if err != nil { return err } diff --git a/server/browser/browser.go b/server/browser/browser.go index c2cb94d637..0815835e01 100644 --- a/server/browser/browser.go +++ b/server/browser/browser.go @@ -1,4 +1,4 @@ -// Package browser provides the ability to send WebP images to a browser over +// Package browser provides the ability to send images to a browser over // websockets. package browser @@ -19,17 +19,18 @@ import ( "tidbyt.dev/pixlet/server/loader" ) -// Browser provides a structure for serving WebP images over websockets to +// Browser provides a structure for serving WebP or GIF images over websockets to // a web browser. type Browser struct { addr string // The address to listen on. title string // The title of the HTML document. - updateChan chan loader.Update // A channel of base64 encoded WebP images. + updateChan chan loader.Update // A channel of base64 encoded images. watch bool fo *fanout.Fanout r *mux.Router tmpl *template.Template loader *loader.Loader + serveGif bool // True if serving GIF, false if serving WebP } //go:embed preview-mask.png @@ -43,10 +44,11 @@ var previewHTML string // previewData is used to populate the HTML template. type previewData struct { - Title string `json:"title"` - WebP string `json:"webp"` - Watch bool `json:"-"` - Err string `json:"error,omitempty"` + Title string `json:"title"` + Image string `json:"img"` + ImageType string `json:"img_type"` + Watch bool `json:"-"` + Err string `json:"error,omitempty"` } type handlerRequest struct { ID string `json:"id"` @@ -54,7 +56,7 @@ type handlerRequest struct { } // NewBrowser sets up a browser structure. Call Run() to kick off the main loops. -func NewBrowser(addr string, title string, watch bool, updateChan chan loader.Update, l *loader.Loader) (*Browser, error) { +func NewBrowser(addr string, title string, watch bool, updateChan chan loader.Update, l *loader.Loader, serveGif bool) (*Browser, error) { tmpl, err := template.New("preview").Parse(previewHTML) if err != nil { return nil, err @@ -68,6 +70,7 @@ func NewBrowser(addr string, title string, watch bool, updateChan chan loader.Up title: title, loader: l, watch: watch, + serveGif: serveGif, } r := mux.NewRouter() @@ -92,6 +95,7 @@ func NewBrowser(addr string, title string, watch bool, updateChan chan loader.Up // API endpoints to support the React frontend. r.HandleFunc("/api/v1/preview", b.previewHandler) r.HandleFunc("/api/v1/preview.webp", b.imageHandler) + r.HandleFunc("/api/v1/preview.gif", b.imageHandler) r.HandleFunc("/api/v1/push", b.pushHandler) r.HandleFunc("/api/v1/schema", b.schemaHandler).Methods("GET") r.HandleFunc("/api/v1/handlers/{handler}", b.schemaHandlerHandler).Methods("POST") @@ -103,7 +107,7 @@ func NewBrowser(addr string, title string, watch bool, updateChan chan loader.Up // Run starts the server process and runs forever in a blocking fashion. The // main routines include an update watcher to process incomming changes to the -// webp and running the http handlers. +// image and running the http handlers. func (b *Browser) Run() error { defer b.fo.Quit() @@ -170,17 +174,21 @@ func (b *Browser) imageHandler(w http.ResponseWriter, r *http.Request) { config[k] = val[0] } - webp, err := b.loader.LoadApplet(config) + img, err := b.loader.LoadApplet(config) if err != nil { http.Error(w, "loading applet", http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "image/webp") + img_type := "image/webp" + if b.serveGif { + img_type = "image/gif" + } + w.Header().Set("Content-Type", img_type) - data, err := base64.StdEncoding.DecodeString(webp) + data, err := base64.StdEncoding.DecodeString(img) if err != nil { - http.Error(w, "decoding webp", http.StatusInternalServerError) + http.Error(w, "decoding image", http.StatusInternalServerError) return } @@ -199,10 +207,15 @@ func (b *Browser) previewHandler(w http.ResponseWriter, r *http.Request) { config[k] = val[0] } - webp, err := b.loader.LoadApplet(config) + img, err := b.loader.LoadApplet(config) + img_type := "webp" + if b.serveGif { + img_type = "gif" + } data := &previewData{ - WebP: webp, - Title: b.title, + Image: img, + ImageType: img_type, + Title: b.title, } if err != nil { data.Err = err.Error() @@ -238,13 +251,19 @@ func (b *Browser) websocketHandler(w http.ResponseWriter, r *http.Request) { } func (b *Browser) updateWatcher() error { + img_type := "webp" + if b.serveGif { + img_type = "gif" + } + for { select { case up := <-b.updateChan: b.fo.Broadcast( fanout.WebsocketEvent{ - Type: fanout.EventTypeWebP, - Message: up.WebP, + Type: fanout.EventTypeImage, + Message: up.Image, + ImageType: img_type, }, ) @@ -279,12 +298,12 @@ func (b *Browser) oldRootHandler(w http.ResponseWriter, r *http.Request) { config[k] = vals[0] } - webp, err := b.loader.LoadApplet(config) + img, err := b.loader.LoadApplet(config) data := previewData{ Title: b.title, Watch: b.watch, - WebP: webp, + Image: img, } if err != nil { diff --git a/server/browser/preview.html b/server/browser/preview.html index ba79b409dd..94a261a662 100644 --- a/server/browser/preview.html +++ b/server/browser/preview.html @@ -20,7 +20,7 @@
- +

{{ .Err }}

@@ -53,8 +53,8 @@ const err = document.getElementById("errors"); switch (data.type) { - case "webp": - img.src = "data:image/webp;base64," + data.message; + case "img": + img.src = "data:image/" + data.img_type + ";base64," + data.message; err.innerHTML = ""; break; case "error": diff --git a/server/browser/push.go b/server/browser/push.go index 5b65aae07f..406abcb8e2 100644 --- a/server/browser/push.go +++ b/server/browser/push.go @@ -53,12 +53,12 @@ func (b *Browser) pushHandler(w http.ResponseWriter, r *http.Request) { } } - webp, err := b.loader.LoadApplet(config) + img, err := b.loader.LoadApplet(config) payload, err := json.Marshal( TidbytPushJSON{ DeviceID: deviceID, - Image: webp, + Image: img, InstallationID: installationID, Background: background, }, diff --git a/server/fanout/client.go b/server/fanout/client.go index e0b9cda0f0..82048e3441 100644 --- a/server/fanout/client.go +++ b/server/fanout/client.go @@ -52,7 +52,7 @@ func (f *Fanout) NewClient(conn *websocket.Conn) *Client { return c } -// Send is used to send a webp message to the client. +// Send is used to send an image message to the client. func (c *Client) Send(event WebsocketEvent) { c.send <- event } @@ -82,7 +82,7 @@ func (c *Client) reader() { } } -// writer writes webp events over the socket when it recieves messages via +// writer writes image events over the socket when it recieves messages via // Send(). It also sends pings to ensure the connection stays alive. func (c *Client) writer() { ticker := time.NewTicker(pingPeriod) diff --git a/server/fanout/event.go b/server/fanout/event.go index 19302674f8..ea8807f57b 100644 --- a/server/fanout/event.go +++ b/server/fanout/event.go @@ -1,24 +1,27 @@ package fanout const ( - // EventTypeWebP is used to signal what type of message we are sending over + // EventTypeImage is used to signal what type of message we are sending over // the socket. - EventTypeWebP = "webp" + EventTypeImage = "img" // EventTypeSchema is used to signal that the schema for a given app has // changed. EventTypeSchema = "schema" // EventTypeErr is used to signal there was an error encountered rendering - // the WebP image. + // the image. EventTypeErr = "error" ) // WebsocketEvent is a structure used to send messages over the socket. type WebsocketEvent struct { - // Message is the contents of the message. This is a webp, base64 encoded. + // Message is the contents of the message. This is a webp or gif, base64 encoded. Message string `json:"message"` + // ImageType indicates whether the Message is webp or gif image. + ImageType string `json:"img_type"` + // Type is the type of message we are sending over the socket. Type string `json:"type"` } diff --git a/server/loader/loader.go b/server/loader/loader.go index 4f04f68983..cee8a911b9 100644 --- a/server/loader/loader.go +++ b/server/loader/loader.go @@ -30,12 +30,14 @@ type Loader struct { maxDuration int initialLoad chan bool timeout int + renderGif bool } type Update struct { - WebP string - Schema string - Err error + Image string + ImageType string + Schema string + Err error } // NewLoader instantiates a new loader structure. The loader will read off of @@ -49,6 +51,7 @@ func NewLoader( updatesChan chan Update, maxDuration int, timeout int, + renderGif bool, ) (*Loader, error) { l := &Loader{ fs: fs, @@ -62,6 +65,7 @@ func NewLoader( maxDuration: maxDuration, initialLoad: make(chan bool), timeout: timeout, + renderGif: renderGif, } cache := runtime.NewInMemoryCache() @@ -95,12 +99,16 @@ func (l *Loader) Run() error { case <-l.requestedChanges: up := Update{} - webp, err := l.loadApplet(config) + img, err := l.loadApplet(config) if err != nil { log.Printf("error loading applet: %v", err) up.Err = err } else { - up.WebP = webp + up.Image = img + up.ImageType = "webp" + if l.renderGif { + up.ImageType = "gif" + } } l.updatesChan <- up @@ -109,12 +117,16 @@ func (l *Loader) Run() error { log.Println("detected updates, reloading") up := Update{} - webp, err := l.loadApplet(config) + img, err := l.loadApplet(config) if err != nil { log.Printf("error loading applet: %v", err) up.Err = err } else { - up.WebP = webp + up.Image = img + up.ImageType = "webp" + if l.renderGif { + up.ImageType = "gif" + } up.Schema = string(l.applet.SchemaJSON) } @@ -134,7 +146,7 @@ func (l *Loader) LoadApplet(config map[string]string) (string, error) { l.configChanges <- config l.requestedChanges <- true result := <-l.resultsChan - return result.WebP, result.Err + return result.Image, result.Err } func (l *Loader) GetSchema() []byte { @@ -182,12 +194,17 @@ func (l *Loader) loadApplet(config map[string]string) (string, error) { if screens.ShowFullAnimation { maxDuration = 0 } - webp, err := screens.EncodeWebP(maxDuration) + + var img []byte + if l.renderGif { + img, err = screens.EncodeGIF(maxDuration) + } else { + img, err = screens.EncodeWebP(maxDuration) + } if err != nil { return "", fmt.Errorf("error rendering: %w", err) } - - return base64.StdEncoding.EncodeToString(webp), nil + return base64.StdEncoding.EncodeToString(img), nil } func (l *Loader) markInitialLoadComplete() { diff --git a/server/server.go b/server/server.go index 6ff5459674..d125dda425 100644 --- a/server/server.go +++ b/server/server.go @@ -23,7 +23,7 @@ type Server struct { } // NewServer creates a new server initialized with the applet. -func NewServer(host string, port int, watch bool, path string, maxDuration int, timeout int) (*Server, error) { +func NewServer(host string, port int, watch bool, path string, maxDuration int, timeout int, serveGif bool) (*Server, error) { fileChanges := make(chan bool, 100) // check if path exists, and whether it is a directory or a file @@ -47,13 +47,13 @@ func NewServer(host string, port int, watch bool, path string, maxDuration int, } updatesChan := make(chan loader.Update, 100) - l, err := loader.NewLoader(fs, watch, fileChanges, updatesChan, maxDuration, timeout) + l, err := loader.NewLoader(fs, watch, fileChanges, updatesChan, maxDuration, timeout, serveGif) if err != nil { return nil, err } addr := fmt.Sprintf("%s:%d", host, port) - b, err := browser.NewBrowser(addr, filepath.Base(path), watch, updatesChan, l) + b, err := browser.NewBrowser(addr, filepath.Base(path), watch, updatesChan, l, serveGif) if err != nil { return nil, err } diff --git a/src/features/config/ConfigManager.jsx b/src/features/config/ConfigManager.jsx index 6b4facfff6..87439da5d1 100644 --- a/src/features/config/ConfigManager.jsx +++ b/src/features/config/ConfigManager.jsx @@ -33,7 +33,7 @@ export default function ConfigManager() { formData.set(id, item.value); }); - if (!loading || !('webp' in preview)) { + if (!loading || !('img' in preview)) { updatePreviews(formData, params); } }, [config]); diff --git a/src/features/controls/Controls.jsx b/src/features/controls/Controls.jsx index 9613a5586c..8a9a0749e4 100644 --- a/src/features/controls/Controls.jsx +++ b/src/features/controls/Controls.jsx @@ -12,7 +12,7 @@ export default function Controls() { const dispatch = useDispatch(); let imageType = 'webp'; - if (PIXLET_WASM) { + if (PIXLET_WASM || preview.value.img_type === "gif") { imageType = 'gif'; } @@ -21,7 +21,7 @@ export default function Controls() { const element = document.createElement("a"); // convert base64 to raw binary data held in a string - let byteCharacters = atob(preview.value.webp); + let byteCharacters = atob(preview.value.img); // create an ArrayBuffer with a size in bytes let arrayBuffer = new ArrayBuffer(byteCharacters.length); diff --git a/src/features/preview/Preview.jsx b/src/features/preview/Preview.jsx index b12a6f8422..2c44ac5b30 100644 --- a/src/features/preview/Preview.jsx +++ b/src/features/preview/Preview.jsx @@ -60,16 +60,16 @@ export default function Preview() { const preview = useSelector(state => state.preview); let displayType = 'data:image/webp;base64,'; - if (PIXLET_WASM) { + if (PIXLET_WASM || preview.value.img_type === "gif") { displayType = 'data:image/gif;base64,'; } - let webp = 'UklGRhoAAABXRUJQVlA4TA4AAAAvP8AHAAcQEf0PRET/Aw=='; - if (preview.value.webp) { - webp = preview.value.webp; + let img = 'UklGRhoAAABXRUJQVlA4TA4AAAAvP8AHAAcQEf0PRET/Aw=='; + if (preview.value.img) { + img = preview.value.img; } - let content = + let content = if (preview.loading && PIXLET_WASM) { content = } diff --git a/src/features/preview/previewSlice.js b/src/features/preview/previewSlice.js index cf5a703d93..32d87406eb 100644 --- a/src/features/preview/previewSlice.js +++ b/src/features/preview/previewSlice.js @@ -5,7 +5,8 @@ export const previewSlice = createSlice({ initialState: { loading: false, value: { - webp: '', + img: '', + img_type: '', title: 'Pixlet', } }, @@ -13,8 +14,12 @@ export const previewSlice = createSlice({ update: (state = initialState, action) => { let up = state; - if ('webp' in action.payload) { - up.value.webp = action.payload.webp; + if ('img' in action.payload) { + up.value.img = action.payload.img; + } + + if ('img_type' in action.payload) { + up.value.img_type = action.payload.img_type; } if ('title' in action.payload) { diff --git a/src/features/watcher/watcher.js b/src/features/watcher/watcher.js index 7527f6e841..3ba1266668 100644 --- a/src/features/watcher/watcher.js +++ b/src/features/watcher/watcher.js @@ -27,9 +27,10 @@ export default class Watcher { const data = JSON.parse(e.data); switch (data.type) { - case 'webp': + case 'img': store.dispatch(update({ - webp: data.message, + img: data.message, + img_type: data.img_type })); store.dispatch(clearErrors()); break;