From e41bf1e03e62f94a40d91208021be87c4331bd90 Mon Sep 17 00:00:00 2001 From: Mats Linander Date: Wed, 1 Feb 2023 16:05:21 -0500 Subject: [PATCH] add configurable max duration for animations (#611) --- cmd/render.go | 17 +++++- cmd/serve.go | 3 +- docs/gen_widget_imgs.go | 2 +- encode/encode.go | 29 +++++++++-- encode/encode_bench_test.go | 2 +- encode/encode_test.go | 101 +++++++++++++++++++++++++++++++++++- server/loader/loader.go | 13 ++++- server/server.go | 4 +- 8 files changed, 157 insertions(+), 14 deletions(-) diff --git a/cmd/render.go b/cmd/render.go index 44a051eace..22854b6e6b 100644 --- a/cmd/render.go +++ b/cmd/render.go @@ -4,6 +4,7 @@ import ( "fmt" "image" "io/ioutil" + "math" "os" "strings" @@ -18,6 +19,7 @@ var ( output string magnify int renderGif bool + maxDuration int silenceOutput bool ) @@ -32,6 +34,13 @@ func init() { 1, "Increase image dimension by a factor (useful for debugging)", ) + RenderCmd.Flags().IntVarP( + &maxDuration, + "max_duration", + "d", + 15000, + "Maximum allowed animation duration (ms).", + ) } var RenderCmd = &cobra.Command{ @@ -130,10 +139,14 @@ func render(cmd *cobra.Command, args []string) error { var buf []byte + if screens.ShowFullAnimation || maxDuration == 0 { + maxDuration = math.MaxInt + } + if renderGif { - buf, err = screens.EncodeGIF(filter) + buf, err = screens.EncodeGIF(maxDuration, filter) } else { - buf, err = screens.EncodeWebP(filter) + buf, err = screens.EncodeWebP(maxDuration, filter) } if err != nil { return fmt.Errorf("error rendering: %w", err) diff --git a/cmd/serve.go b/cmd/serve.go index 0de0880a31..4b943a35ba 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -16,6 +16,7 @@ func init() { ServeCmd.Flags().StringVarP(&host, "host", "i", "127.0.0.1", "Host interface for serving rendered images") ServeCmd.Flags().IntVarP(&port, "port", "p", 8080, "Port for serving rendered images") ServeCmd.Flags().BoolVarP(&watch, "watch", "w", true, "Reload scripts on change") + ServeCmd.Flags().IntVarP(&maxDuration, "max_duration", "d", 15000, "Maximum allowed animation duration (ms)") } var ServeCmd = &cobra.Command{ @@ -26,7 +27,7 @@ var ServeCmd = &cobra.Command{ } func serve(cmd *cobra.Command, args []string) error { - s, err := server.NewServer(host, port, watch, args[0]) + s, err := server.NewServer(host, port, watch, args[0], maxDuration) if err != nil { return err } diff --git a/docs/gen_widget_imgs.go b/docs/gen_widget_imgs.go index 76ad889ca8..db31f7894f 100644 --- a/docs/gen_widget_imgs.go +++ b/docs/gen_widget_imgs.go @@ -78,7 +78,7 @@ def main(): panic(err) } - gif, err := encode.ScreensFromRoots(roots).EncodeGIF(Magnify) + gif, err := encode.ScreensFromRoots(roots).EncodeGIF(15000, Magnify) if err != nil { panic(err) } diff --git a/encode/encode.go b/encode/encode.go index aa9607cef7..2100e37624 100644 --- a/encode/encode.go +++ b/encode/encode.go @@ -94,7 +94,7 @@ func (s *Screens) Hash() ([]byte, error) { // Renders a screen to WebP. Optionally pass filters for // postprocessing each individual frame. -func (s *Screens) EncodeWebP(filters ...ImageFilter) ([]byte, error) { +func (s *Screens) EncodeWebP(maxDuration int, filters ...ImageFilter) ([]byte, error) { images, err := s.render(filters...) if err != nil { return nil, err @@ -116,11 +116,21 @@ func (s *Screens) EncodeWebP(filters ...ImageFilter) ([]byte, error) { } defer anim.Close() - frameDuration := time.Duration(s.delay) * time.Millisecond + remainingDuration := time.Duration(maxDuration) * time.Millisecond for _, im := range images { + frameDuration := time.Duration(s.delay) * time.Millisecond + if frameDuration > remainingDuration { + frameDuration = remainingDuration + } + remainingDuration -= frameDuration + if err := anim.AddFrame(im, frameDuration); err != nil { return nil, errors.Wrap(err, "adding frame") } + + if remainingDuration <= 0 { + break + } } buf, err := anim.Assemble() @@ -133,7 +143,7 @@ func (s *Screens) EncodeWebP(filters ...ImageFilter) ([]byte, error) { // Renders a screen to GIF. Optionally pass filters for postprocessing // each individual frame. -func (s *Screens) EncodeGIF(filters ...ImageFilter) ([]byte, error) { +func (s *Screens) EncodeGIF(maxDuration int, filters ...ImageFilter) ([]byte, error) { images, err := s.render(filters...) if err != nil { return nil, err @@ -145,6 +155,7 @@ func (s *Screens) EncodeGIF(filters ...ImageFilter) ([]byte, error) { g := &gif.GIF{} + remainingDuration := maxDuration for imIdx, im := range images { imRGBA, ok := im.(*image.RGBA) if !ok { @@ -155,8 +166,18 @@ func (s *Screens) EncodeGIF(filters ...ImageFilter) ([]byte, error) { imPaletted := image.NewPaletted(imRGBA.Bounds(), palette) draw.Draw(imPaletted, imRGBA.Bounds(), imRGBA, image.Point{0, 0}, draw.Src) + frameDelay := int(s.delay) + if frameDelay > remainingDuration { + frameDelay = remainingDuration + } + remainingDuration -= frameDelay + g.Image = append(g.Image, imPaletted) - g.Delay = append(g.Delay, int(s.delay/10)) // in 100ths of a second + g.Delay = append(g.Delay, frameDelay/10) // in 100ths of a second + + if remainingDuration <= 0 { + break + } } buf := &bytes.Buffer{} diff --git a/encode/encode_bench_test.go b/encode/encode_bench_test.go index 99803e941d..626f6dc674 100644 --- a/encode/encode_bench_test.go +++ b/encode/encode_bench_test.go @@ -84,7 +84,7 @@ func BenchmarkRunAndRender(b *testing.B) { b.Error(err) } - webp, err := ScreensFromRoots(roots).EncodeWebP() + webp, err := ScreensFromRoots(roots).EncodeWebP(15000) if err != nil { b.Error(err) } diff --git a/encode/encode_test.go b/encode/encode_test.go index bf769d76a3..9b157918d3 100644 --- a/encode/encode_test.go +++ b/encode/encode_test.go @@ -1,11 +1,14 @@ package encode import ( + "bytes" + "image/gif" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/tidbyt/go-libwebp/webp" "tidbyt.dev/pixlet/render" "tidbyt.dev/pixlet/runtime" ) @@ -148,7 +151,7 @@ func TestFile(t *testing.T) { roots, err := app.Run(map[string]string{}) assert.NoError(t, err) - webp, err := ScreensFromRoots(roots).EncodeWebP() + webp, err := ScreensFromRoots(roots).EncodeWebP(15000) assert.NoError(t, err) assert.True(t, len(webp) > 0) } @@ -251,3 +254,99 @@ def main(): assert.NoError(t, err) assert.False(t, ScreensFromRoots(roots).ShowFullAnimation) } + +func TestMaxDuration(t *testing.T) { + src := []byte(` +load("render.star", "render") + +def main(): + return render.Root( + delay = 500, + child = render.Marquee( + width = 64, + offset_end = 65, + child = render.Row( + children = [ + render.Box(width = 35, height = 1, color = "#f00"), + render.Box(width = 35, height = 1, color = "#0f0"), + ], + ), + ), + ) +`) + + app := runtime.Applet{} + err := app.Load("test.star", src, nil) + assert.NoError(t, err) + + roots, err := app.Run(map[string]string{}) + assert.NoError(t, err) + + // Source above will produce a 70 frame animation + assert.Equal(t, 70, roots[0].Child.FrameCount()) + + // These decode gif/webp and return all frame delays and + // their sum in milliseconds. + gifDelays := func(gifData []byte) []int { + im, err := gif.DecodeAll(bytes.NewBuffer(gifData)) + assert.NoError(t, err) + delays := []int{} + for _, d := range im.Delay { + delays = append(delays, d*10) + } + return delays + } + webpDelays := func(webpData []byte) []int { + decoder, err := webp.NewAnimationDecoder(webpData) + assert.NoError(t, err) + img, err := decoder.Decode() + assert.NoError(t, err) + delays := []int{} + last := 0 + for _, t := range img.Timestamp { + d := t - last + last = t + delays = append(delays, d) + } + return delays + } + + // With 500ms delay per frame, total duration will be + // 50000. The encode methods should truncate this down to + // whatever fits in the maxDuration. + + // 3000 ms -> 6 frames, 500 ms each. + gifData, err := ScreensFromRoots(roots).EncodeGIF(3000) + assert.NoError(t, err) + webpData, err := ScreensFromRoots(roots).EncodeWebP(3000) + assert.NoError(t, err) + assert.Equal(t, []int{500, 500, 500, 500, 500, 500}, gifDelays(gifData)) + assert.Equal(t, []int{500, 500, 500, 500, 500, 500}, webpDelays(webpData)) + + // 2200 ms -> 5 frames, with last given only 200ms + gifData, err = ScreensFromRoots(roots).EncodeGIF(2200) + assert.NoError(t, err) + webpData, err = ScreensFromRoots(roots).EncodeWebP(2200) + assert.NoError(t, err) + assert.Equal(t, []int{500, 500, 500, 500, 200}, gifDelays(gifData)) + assert.Equal(t, []int{500, 500, 500, 500, 200}, webpDelays(webpData)) + + // 100 ms -> single frame. Its duration will differ between + // gif and webp, but is also irrelevant. + gifData, err = ScreensFromRoots(roots).EncodeGIF(100) + assert.NoError(t, err) + webpData, err = ScreensFromRoots(roots).EncodeWebP(100) + assert.NoError(t, err) + assert.Equal(t, []int{100}, gifDelays(gifData)) + assert.Equal(t, []int{0}, webpDelays(webpData)) + + // 60000 ms -> all 100 frames, 500 ms each. + gifData, err = ScreensFromRoots(roots).EncodeGIF(60000) + assert.NoError(t, err) + webpData, err = ScreensFromRoots(roots).EncodeWebP(60000) + assert.NoError(t, err) + assert.Equal(t, gifDelays(gifData), webpDelays(webpData)) + for _, d := range gifDelays(gifData) { + assert.Equal(t, 500, d) + } +} diff --git a/server/loader/loader.go b/server/loader/loader.go index 40b280ce31..fc39c317fa 100644 --- a/server/loader/loader.go +++ b/server/loader/loader.go @@ -26,6 +26,7 @@ type Loader struct { requestedChanges chan bool updatesChan chan Update resultsChan chan Update + maxDuration int } type Update struct { @@ -38,7 +39,14 @@ type Update struct { // fileChanges channel and write updates to the updatesChan. Updates are base64 // encoded WebP strings. If watch is enabled, both file changes and on demand // requests will send updates over the updatesChan. -func NewLoader(filename string, watch bool, fileChanges chan bool, updatesChan chan Update) (*Loader, error) { +func NewLoader( + filename string, + watch bool, + fileChanges chan bool, + updatesChan chan Update, + maxDuration int, +) (*Loader, error) { + l := &Loader{ filename: filename, fileChanges: fileChanges, @@ -48,6 +56,7 @@ func NewLoader(filename string, watch bool, fileChanges chan bool, updatesChan c configChanges: make(chan map[string]string, 100), requestedChanges: make(chan bool, 100), resultsChan: make(chan Update, 100), + maxDuration: maxDuration, } runtime.InitCache(runtime.NewInMemoryCache()) @@ -145,7 +154,7 @@ func (l *Loader) loadApplet(config map[string]string) (string, error) { return "", fmt.Errorf("error running script: %w", err) } - webp, err := encode.ScreensFromRoots(roots).EncodeWebP() + webp, err := encode.ScreensFromRoots(roots).EncodeWebP(l.maxDuration) if err != nil { return "", fmt.Errorf("error rendering: %w", err) } diff --git a/server/server.go b/server/server.go index f7e182466c..a2d18463dd 100644 --- a/server/server.go +++ b/server/server.go @@ -19,12 +19,12 @@ type Server struct { } // NewServer creates a new server initialized with the applet. -func NewServer(host string, port int, watch bool, filename string) (*Server, error) { +func NewServer(host string, port int, watch bool, filename string, maxDuration int) (*Server, error) { fileChanges := make(chan bool, 100) w := watcher.NewWatcher(filename, fileChanges) updatesChan := make(chan loader.Update, 100) - l, err := loader.NewLoader(filename, watch, fileChanges, updatesChan) + l, err := loader.NewLoader(filename, watch, fileChanges, updatesChan, maxDuration) if err != nil { return nil, err }