export gifs

This commit is contained in:
Júlio César Batista 2023-04-14 10:35:14 -03:00
parent 9fc5a5f4ce
commit 8b3fea259c
No known key found for this signature in database
GPG key ID: 10C4B861BF314878
4 changed files with 137 additions and 36 deletions

View file

@ -355,6 +355,26 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende
ext := getExportExtension(outputPath) ext := getExportExtension(outputPath)
switch ext { switch ext {
case GIF:
svg, pngs, err := renderPNGsForGIF(ctx, ms, plugin, renderOpts, ruler, page, diagram)
if err != nil {
return nil, false, err
}
out, err := xgif.AnimatePNGs(pngs, int(animateInterval))
if err != nil {
return nil, false, err
}
err = os.MkdirAll(filepath.Dir(outputPath), 0755)
if err != nil {
return nil, false, err
}
err = ms.WritePath(outputPath, out)
if err != nil {
return nil, false, err
}
dur := time.Since(start)
ms.Log.Success.Printf("successfully compiled %s to %s in %s", ms.HumanPath(inputPath), ms.HumanPath(outputPath), dur)
return svg, true, nil
case PDF: case PDF:
pageMap := buildBoardIDToIndex(diagram, nil, nil) pageMap := buildBoardIDToIndex(diagram, nil, nil)
pdf, err := renderPDF(ctx, ms, plugin, renderOpts, outputPath, page, ruler, diagram, nil, nil, pageMap) pdf, err := renderPDF(ctx, ms, plugin, renderOpts, outputPath, page, ruler, diagram, nil, nil, pageMap)
@ -401,19 +421,11 @@ func compile(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, rende
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
// TODO: test GIF with 1 frame
var out []byte var out []byte
if len(boards) > 0 { if len(boards) > 0 {
out = boards[0] out = boards[0]
if animateInterval > 0 { if animateInterval > 0 && ext != GIF {
if ext == GIF { out, err = d2animate.Wrap(diagram, boards, renderOpts, int(animateInterval))
tl, br := diagram.NestedBoundingBox()
width := png.SCALE*(br.X-tl.X) + renderOpts.Pad*2
height := png.SCALE*(br.Y-tl.Y) + renderOpts.Pad*2
out, err = xgif.AnimatePNGs(boards, width, height, int(animateInterval))
} else {
out, err = d2animate.Wrap(diagram, boards, renderOpts, int(animateInterval))
}
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
@ -610,7 +622,7 @@ func render(ctx context.Context, ms *xmain.State, compileDur time.Duration, plug
} }
func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) { func _render(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, outputPath string, bundle, forceAppendix bool, page playwright.Page, ruler *textmeasure.Ruler, diagram *d2target.Diagram) ([]byte, error) {
toPNG := getExportExtension(outputPath).requiresPNGRenderer() toPNG := getExportExtension(outputPath) == PNG
svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{ svg, err := d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: opts.Pad, Pad: opts.Pad,
Sketch: opts.Sketch, Sketch: opts.Sketch,
@ -1001,3 +1013,61 @@ func buildBoardIDToIndex(diagram *d2target.Diagram, dictionary map[string]int, p
return dictionary return dictionary
} }
func renderPNGsForGIF(ctx context.Context, ms *xmain.State, plugin d2plugin.Plugin, opts d2svg.RenderOpts, ruler *textmeasure.Ruler, page playwright.Page, diagram *d2target.Diagram) (svg []byte, pngs [][]byte, err error) {
if !diagram.IsFolderOnly {
svg, err = d2svg.Render(diagram, &d2svg.RenderOpts{
Pad: opts.Pad,
Sketch: opts.Sketch,
Center: opts.Center,
SetDimensions: true,
})
if err != nil {
return nil, nil, err
}
svg, err = plugin.PostProcess(ctx, svg)
if err != nil {
return nil, nil, err
}
svg, bundleErr := imgbundler.BundleLocal(ctx, ms, svg)
svg, bundleErr2 := imgbundler.BundleRemote(ctx, ms, svg)
bundleErr = multierr.Combine(bundleErr, bundleErr2)
if bundleErr != nil {
return nil, nil, bundleErr
}
svg = appendix.Append(diagram, ruler, svg)
pngImg, err := png.ConvertSVG(ms, page, svg)
if err != nil {
return nil, nil, err
}
pngs = append(pngs, pngImg)
}
for _, dl := range diagram.Layers {
_, layerPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, dl)
if err != nil {
return nil, nil, err
}
pngs = append(pngs, layerPNGs...)
}
for _, dl := range diagram.Scenarios {
_, scenarioPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, dl)
if err != nil {
return nil, nil, err
}
pngs = append(pngs, scenarioPNGs...)
}
for _, dl := range diagram.Steps {
_, stepsPNGs, err := renderPNGsForGIF(ctx, ms, plugin, opts, ruler, page, dl)
if err != nil {
return nil, nil, err
}
pngs = append(pngs, stepsPNGs...)
}
return svg, pngs, nil
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 206 KiB

After

Width:  |  Height:  |  Size: 219 KiB

View file

@ -4,32 +4,47 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"image" "image"
"image/color"
"image/gif" "image/gif"
"image/png" "image/png"
"github.com/ericpauley/go-quantize/quantize" "github.com/ericpauley/go-quantize/quantize"
"oss.terrastruct.com/util-go/go2"
) )
const INFINITE_LOOP = 0 const INFINITE_LOOP = 0
const BG_INDEX uint8 = 255
var BG_COLOR = color.White
func AnimatePNGs(pngs [][]byte, animIntervalMs int) ([]byte, error) {
var width, height int
pngImgs := make([]image.Image, len(pngs))
for i, pngBytes := range pngs {
img, err := png.Decode(bytes.NewBuffer(pngBytes))
if err != nil {
return nil, err
}
pngImgs[i] = img
bounds := img.Bounds()
width = go2.Max(width, bounds.Dx())
height = go2.Max(height, bounds.Dy())
}
func AnimatePNGs(pngs [][]byte, gifWidth, gifHeight int, animIntervalMs int) ([]byte, error) {
interval := animIntervalMs / 10 // gif animation interval is in 100ths of a second interval := animIntervalMs / 10 // gif animation interval is in 100ths of a second
anim := &gif.GIF{ anim := &gif.GIF{
LoopCount: INFINITE_LOOP, LoopCount: INFINITE_LOOP,
Config: image.Config{ Config: image.Config{
Width: gifWidth, Width: width,
Height: gifHeight, Height: height,
}, },
} }
for _, pngBytes := range pngs { for _, pngImage := range pngImgs {
pngImage, err := png.Decode(bytes.NewBuffer(pngBytes)) // 1. convert the PNG into a GIF compatible image (Bitmap) by quantizing it to 255 colors
if err != nil {
return nil, err
}
buf := bytes.NewBuffer(nil) buf := bytes.NewBuffer(nil)
err = gif.Encode(buf, pngImage, &gif.Options{ err := gif.Encode(buf, pngImage, &gif.Options{
NumColors: 256, // GIFs can have up to 256 colors NumColors: 255, // GIFs can have up to 256 colors, so keep 1 slot for white background
Quantizer: quantize.MedianCutQuantizer{}, Quantizer: quantize.MedianCutQuantizer{},
}) })
if err != nil { if err != nil {
@ -43,7 +58,27 @@ func AnimatePNGs(pngs [][]byte, gifWidth, gifHeight int, animIntervalMs int) ([]
if !ok { if !ok {
return nil, fmt.Errorf("decoded git image could not be cast as *image.Paletted") return nil, fmt.Errorf("decoded git image could not be cast as *image.Paletted")
} }
anim.Image = append(anim.Image, palettedImg)
// 2. make GIF frames of the same size, keeping images centered and with a white background
bounds := pngImage.Bounds()
top := (height - bounds.Dy()) / 2
bottom := top + bounds.Dy()
left := (width - bounds.Dx()) / 2
right := left + bounds.Dx()
palettedImg.Palette[BG_INDEX] = BG_COLOR
frame := image.NewPaletted(image.Rect(0, 0, width, height), palettedImg.Palette)
for x := 0; x < width; x++ {
for y := 0; y < height; y++ {
if x <= left || y <= top || x >= right || y >= bottom {
frame.SetColorIndex(x, y, BG_INDEX)
} else {
frame.SetColorIndex(x, y, palettedImg.ColorIndexAt(x-left, y-top))
}
}
}
anim.Image = append(anim.Image, frame)
anim.Delay = append(anim.Delay, interval) anim.Delay = append(anim.Delay, interval)
} }

View file

@ -1,13 +1,11 @@
package xgif package xgif
import ( import (
"bytes"
_ "embed" _ "embed"
"image/png" "os"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"oss.terrastruct.com/util-go/go2"
) )
//go:embed test_input1.png //go:embed test_input1.png
@ -20,20 +18,18 @@ var test_input2 []byte
var test_output []byte var test_output []byte
func TestPngToGif(t *testing.T) { func TestPngToGif(t *testing.T) {
board1, err := png.Decode(bytes.NewBuffer(test_input1))
assert.NoError(t, err)
gifWidth := board1.Bounds().Dx()
gifHeight := board1.Bounds().Dy()
board2, err := png.Decode(bytes.NewBuffer(test_input2))
assert.NoError(t, err)
gifWidth = go2.Max(board2.Bounds().Dx(), gifWidth)
gifHeight = go2.Max(board2.Bounds().Dy(), gifHeight)
boards := [][]byte{test_input1, test_input2} boards := [][]byte{test_input1, test_input2}
interval := 1_000 interval := 1_000
gifBytes, err := AnimatePNGs(boards, gifWidth, gifHeight, interval) gifBytes, err := AnimatePNGs(boards, interval)
assert.NoError(t, err) assert.NoError(t, err)
// use this to update the test output
if false {
f, err := os.Create("test_output_2.gif")
assert.NoError(t, err)
defer f.Close()
f.Write(gifBytes)
}
assert.Equal(t, test_output, gifBytes) assert.Equal(t, test_output, gifBytes)
} }