Skip to content

Commit

Permalink
add configurable max duration for animations (#611)
Browse files Browse the repository at this point in the history
  • Loading branch information
matslina authored Feb 1, 2023
1 parent 1bb4fe0 commit e41bf1e
Show file tree
Hide file tree
Showing 8 changed files with 157 additions and 14 deletions.
17 changes: 15 additions & 2 deletions cmd/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"image"
"io/ioutil"
"math"
"os"
"strings"

Expand All @@ -18,6 +19,7 @@ var (
output string
magnify int
renderGif bool
maxDuration int
silenceOutput bool
)

Expand All @@ -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{
Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion docs/gen_widget_imgs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
29 changes: 25 additions & 4 deletions encode/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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
Expand All @@ -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 {
Expand All @@ -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{}
Expand Down
2 changes: 1 addition & 1 deletion encode/encode_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
101 changes: 100 additions & 1 deletion encode/encode_test.go
Original file line number Diff line number Diff line change
@@ -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"
)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}
}
13 changes: 11 additions & 2 deletions server/loader/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ type Loader struct {
requestedChanges chan bool
updatesChan chan Update
resultsChan chan Update
maxDuration int
}

type Update struct {
Expand All @@ -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,
Expand All @@ -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())
Expand Down Expand Up @@ -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)
}
Expand Down
4 changes: 2 additions & 2 deletions server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down

0 comments on commit e41bf1e

Please sign in to comment.