export gifs
This commit is contained in:
parent
9fc5a5f4ce
commit
8b3fea259c
4 changed files with 137 additions and 36 deletions
|
|
@ -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 |
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue