├── wasm
├── wasm_exec.js
├── go_worker.js
├── main.go
└── index.html
├── source.jpg
├── go.mod
├── server
└── server.go
├── go.sum
├── LICENSE.md
├── examples
├── grayscale
│ └── main.go
├── resizing
│ └── main.go
└── circular
│ └── main.go
├── cmd
└── main.go
├── README.md
└── sketch
└── sketch.go
/wasm/wasm_exec.js:
--------------------------------------------------------------------------------
1 | // Provide yours here
2 |
--------------------------------------------------------------------------------
/source.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/preslavrachev/generative-art-in-go/HEAD/source.jpg
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/preslavrachev/generative-art-in-go
2 |
3 | go 1.15
4 |
5 | require (
6 | github.com/fogleman/gg v1.3.0
7 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
8 | golang.org/x/image v0.0.0-20201208152932-35266b937fa6 // indirect
9 | )
10 |
--------------------------------------------------------------------------------
/server/server.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "log"
7 | "net/http"
8 | )
9 |
10 | func main() {
11 | port := flag.String("p", "8100", "port to serve on")
12 | directory := flag.String("d", ".", "the directory of static file to host")
13 | flag.Parse()
14 |
15 | http.Handle("/", http.FileServer(http.Dir(*directory)))
16 |
17 | log.Printf("Serving %s on HTTP port: %s\n", *directory, *port)
18 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", *port), nil))
19 | }
20 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
2 | github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
3 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
4 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
5 | golang.org/x/image v0.0.0-20201208152932-35266b937fa6 h1:nfeHNc1nAqecKCy2FCy4HY+soOOe5sDLJ/gZLbx6GYI=
6 | golang.org/x/image v0.0.0-20201208152932-35266b937fa6/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
7 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
8 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Preslav Rachev
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/examples/grayscale/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "image"
6 | "image/color"
7 | _ "image/jpeg"
8 | "image/png"
9 | "log"
10 | "os"
11 | )
12 |
13 | type GrayscaleFilter struct {
14 | image.Image
15 | }
16 |
17 | func (f *GrayscaleFilter) At(x, y int) color.Color {
18 | r, g, b, a := f.Image.At(x, y).RGBA()
19 | grey := uint16(float64(r)*0.21 + float64(g)*0.72 + float64(b)*0.07)
20 |
21 | return color.RGBA64{
22 | R: grey,
23 | G: grey,
24 | B: grey,
25 | A: uint16(a),
26 | }
27 | }
28 |
29 | func main() {
30 | img, err := loadImage("source.jpg")
31 | if err != nil {
32 | log.Panic(err)
33 | }
34 |
35 | saveOutput(&GrayscaleFilter{img}, "out.png")
36 | }
37 |
38 | func loadImage(filePath string) (image.Image, error) {
39 | file, err := os.Open(filePath)
40 | if err != nil {
41 | return nil, fmt.Errorf("source image could not be loaded: %w", err)
42 | }
43 | defer file.Close()
44 | img, _, err := image.Decode(file)
45 | if err != nil {
46 | return nil, fmt.Errorf("source image format could not be decoded: %w", err)
47 | }
48 |
49 | return img, nil
50 | }
51 |
52 | func saveOutput(img image.Image, filePath string) error {
53 | f, err := os.Create(filePath)
54 | if err != nil {
55 | return err
56 | }
57 | defer f.Close()
58 |
59 | // Encode to `PNG` with `DefaultCompression` level
60 | // then save to file
61 | err = png.Encode(f, img)
62 | if err != nil {
63 | return err
64 | }
65 |
66 | return nil
67 | }
68 |
--------------------------------------------------------------------------------
/wasm/go_worker.js:
--------------------------------------------------------------------------------
1 | // hijack the console.log function to capture stdout
2 | let oldLog = console.log;
3 | console.log = (line) => {
4 | postMessage({
5 | log: line,
6 | });
7 | };
8 |
9 | self.importScripts("wasm_exec.js");
10 | //console.log(self.Go);
11 |
12 | // initialize the Go WASM glue
13 | const go = new self.Go();
14 |
15 | let mod, inst;
16 | let result;
17 | WebAssembly.instantiateStreaming(fetch("main.wasm"), go.importObject)
18 | .then((result) => {
19 | mod = result.module;
20 | inst = result.instance;
21 |
22 | go.run(inst);
23 | console.log("WASM Loaded");
24 | })
25 | .catch((err) => {
26 | console.error(err);
27 | });
28 |
29 | self.onmessage = async (msg) => {
30 | switch (msg.data.type) {
31 | case "call":
32 | //console.log("Message received: " + msg.data.call);
33 | console.log("Start Rendering");
34 | args = msg.data.args || [];
35 | await self[msg.data.func](...args);
36 | console.log("Stop Rendering");
37 | break;
38 | case "set":
39 | self[msg.data.prop] = msg.data.value;
40 | break;
41 | default:
42 | console.error("Unavailable message type!");
43 | }
44 | };
45 |
46 | function displayImage(buf) {
47 | let blob = new Blob([buf], { type: imageType });
48 | console.log("Bytes received" + blob);
49 | postMessage({
50 | image: blob,
51 | });
52 | }
53 |
54 | function trackProgress(progress) {
55 | postMessage({
56 | progress: progress,
57 | });
58 | }
59 |
--------------------------------------------------------------------------------
/examples/resizing/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "image"
6 | _ "image/jpeg"
7 | "image/png"
8 | "log"
9 | "os"
10 | )
11 |
12 | func main() {
13 | img, err := loadImage("source.jpg")
14 | if err != nil {
15 | log.Panic(err)
16 | }
17 |
18 | saveOutput(resizeImage(img, 500, 500), "out.png")
19 | }
20 |
21 | func resizeImage(in image.Image, newWidth int, newHeight int) image.Image {
22 | originalWidth, originalHeight := in.Bounds().Dx(), in.Bounds().Dy()
23 | scalingRatioX := float64(originalWidth) / float64(newWidth)
24 | scalingRatioY := float64(originalHeight) / float64(newHeight)
25 |
26 | out := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
27 |
28 | for x := 0; x < newWidth; x++ {
29 | for y := 0; y < newHeight; y++ {
30 | projectedX := int(float64(x) * scalingRatioX)
31 | projectedY := int(float64(y) * scalingRatioY)
32 | out.Set(x, y, in.At(projectedX, projectedY))
33 | }
34 | }
35 |
36 | return out
37 | }
38 |
39 | func loadImage(filePath string) (image.Image, error) {
40 | file, err := os.Open(filePath)
41 | if err != nil {
42 | return nil, fmt.Errorf("source image could not be loaded: %w", err)
43 | }
44 | defer file.Close()
45 | img, _, err := image.Decode(file)
46 | if err != nil {
47 | return nil, fmt.Errorf("source image format could not be decoded: %w", err)
48 | }
49 |
50 | return img, nil
51 | }
52 |
53 | func saveOutput(img image.Image, filePath string) error {
54 | f, err := os.Create(filePath)
55 | if err != nil {
56 | return err
57 | }
58 | defer f.Close()
59 |
60 | // Encode to `PNG` with `DefaultCompression` level
61 | // then save to file
62 | err = png.Encode(f, img)
63 | if err != nil {
64 | return err
65 | }
66 |
67 | return nil
68 | }
69 |
--------------------------------------------------------------------------------
/examples/circular/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "image"
6 | "image/color"
7 | _ "image/jpeg"
8 | "image/png"
9 | "log"
10 | "os"
11 | )
12 |
13 | type CircularMask struct {
14 | source image.Image
15 | center image.Point
16 | radius int
17 | }
18 |
19 | func (c *CircularMask) ColorModel() color.Model {
20 | return c.source.ColorModel()
21 | }
22 |
23 | func (c *CircularMask) Bounds() image.Rectangle {
24 | return image.Rect(c.center.X-c.radius, c.center.Y-c.radius, c.center.X+c.radius, c.center.Y+c.radius)
25 | }
26 |
27 | func (c *CircularMask) At(x, y int) color.Color {
28 | xx, yy, rr := float64(x-c.center.X), float64(y-c.center.Y), float64(c.radius)
29 | if xx*xx+yy*yy < rr*rr {
30 | return c.source.At(x, y)
31 | }
32 | return color.Alpha{0}
33 | }
34 |
35 | func main() {
36 | img, err := loadImage("source.jpg")
37 | if err != nil {
38 | log.Panic(err)
39 | }
40 |
41 | saveOutput(&CircularMask{
42 | source: img,
43 | center: image.Point{
44 | X: 1000,
45 | Y: 2000,
46 | },
47 | radius: 100,
48 | }, "out.png")
49 | }
50 |
51 | func loadImage(filePath string) (image.Image, error) {
52 | file, err := os.Open(filePath)
53 | if err != nil {
54 | return nil, fmt.Errorf("source image could not be loaded: %w", err)
55 | }
56 | defer file.Close()
57 | img, _, err := image.Decode(file)
58 | if err != nil {
59 | return nil, fmt.Errorf("source image format could not be decoded: %w", err)
60 | }
61 |
62 | return img, nil
63 | }
64 |
65 | func saveOutput(img image.Image, filePath string) error {
66 | f, err := os.Create(filePath)
67 | if err != nil {
68 | return err
69 | }
70 | defer f.Close()
71 |
72 | // Encode to `PNG` with `DefaultCompression` level
73 | // then save to file
74 | err = png.Encode(f, img)
75 | if err != nil {
76 | return err
77 | }
78 |
79 | return nil
80 | }
81 |
--------------------------------------------------------------------------------
/cmd/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "image"
6 | "image/png"
7 | "log"
8 | "math/rand"
9 | "net/http"
10 | "os"
11 | "time"
12 |
13 | "github.com/preslavrachev/generative-art-in-go/sketch"
14 | )
15 |
16 | var (
17 | sourceImgName = "source.jpg"
18 | outputImgName = "out.png"
19 | totalCycleCount = 5000
20 | )
21 |
22 | func main() {
23 |
24 | img, err := loadImage(sourceImgName)
25 | /*
26 | use this for completely random results
27 | img, err := loadRandomUnsplashImage(2000, 2000)
28 | */
29 | if err != nil {
30 | log.Panicln(err)
31 | }
32 |
33 | destWidth := 2000
34 | s := sketch.NewSketch(img, sketch.UserParams{
35 | StrokeRatio: 0.75,
36 | DestWidth: destWidth,
37 | DestHeight: 2000,
38 | InitialAlpha: 0.1,
39 | StrokeReduction: 0.002,
40 | AlphaIncrease: 0.06,
41 | StrokeInversionThreshold: 0.05,
42 | StrokeJitter: int(0.1 * float64(destWidth)),
43 | MinEdgeCount: 3,
44 | MaxEdgeCount: 4,
45 | })
46 |
47 | rand.Seed(time.Now().Unix())
48 |
49 | for i := 0; i < totalCycleCount; i++ {
50 | s.Update()
51 | }
52 |
53 | saveOutput(s.Output(), outputImgName)
54 | }
55 |
56 | func loadRandomUnsplashImage(width, height int) (image.Image, error) {
57 | url := fmt.Sprintf("https://source.unsplash.com/random/%dx%d", width, height)
58 | res, err := http.Get(url)
59 | if err != nil {
60 | return nil, err
61 | }
62 | defer res.Body.Close()
63 |
64 | img, _, err := image.Decode(res.Body)
65 | return img, err
66 | }
67 |
68 | func loadImage(src string) (image.Image, error) {
69 | file, _ := os.Open(sourceImgName)
70 | defer file.Close()
71 | img, _, err := image.Decode(file)
72 | return img, err
73 | }
74 |
75 | func saveOutput(img image.Image, filePath string) error {
76 | f, err := os.Create(filePath)
77 | if err != nil {
78 | return err
79 | }
80 | defer f.Close()
81 |
82 | // Encode to `PNG` with `DefaultCompression` level
83 | // then save to file
84 | err = png.Encode(f, img)
85 | if err != nil {
86 | return err
87 | }
88 |
89 | return nil
90 | }
91 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | This is the companion source code repository to my book, ["Generative Art in Go."](https://preslav.me/generative-art-in-golang/) The book is available as an "early access" release on both [Gumroad](https://gumroad.com/l/generative-art-in-golang) and [Leanpub](https://leanpub.com/generative-art-in-golang). While most of the content and surrounding source code are already available, the book is subject to further updates based on readers' feedback. All future updates will be availalble to all exisiting buyers free of charge.
2 |
3 | - [Buy on Gumroad](https://gumroad.com/l/generative-art-in-golang)
4 | - [Buy on Leanpub](https://leanpub.com/generative-art-in-golang)
5 |
6 | # Demo
7 |
8 | To see what you will be able to do by the end of the book, make sure to check out this experimental [Web application](https://goart.netlify.app/) I built a couple of weeks ago. It uses WebAssembly to generate beatiful imagery in the browser. And that, using Go code!
9 | # About the Book
10 |
11 |
12 |
13 | Generative Art in Go is a short eBook intended to introduce novice and experienced programmers to algorithmic art, and hopefully, get non-programmers interested in the [Go programming language](https://golang.org/). Unlike popular choices such as [Processing](https://processing.org/), [NodeBox](https://www.nodebox.net/), [openFrameworks](https://openframeworks.cc/), etc., I picked Go for two simple reasons:
14 |
15 | - Go's syntax is easy enough that virtually anyone can learn it quickly.
16 | - The language is far more versatile than what most programmers use it for.
17 |
18 | Generative art is a unique form of artistic expression, building bridges between computer programming, randomness, and visual aesthetics. It has kept my imagination for over a decade. While I have never been particularly skilled at drawing, I have always been amazed by what one could achieve with a couple of lines of code.
19 |
20 | This book will introduce readers to graphics programming in the Go programming language context through a series of concise examples. It will also guide them in implementing their version of one of my favorite generative art sketches. As a bonus, readers will learn how to turn their command-line sketches into interactive Web applications, using [WebAssembly](https://webassembly.org/) and Web technology's latest standards.
--------------------------------------------------------------------------------
/sketch/sketch.go:
--------------------------------------------------------------------------------
1 | package sketch
2 |
3 | import (
4 | "image"
5 | "image/color"
6 | "math/rand"
7 |
8 | "github.com/fogleman/gg"
9 | )
10 |
11 | type UserParams struct {
12 | StrokeRatio float64
13 | DestWidth int
14 | DestHeight int
15 | InitialAlpha float64
16 | StrokeReduction float64
17 | AlphaIncrease float64
18 | StrokeInversionThreshold float64
19 | StrokeJitter int
20 | MinEdgeCount int
21 | MaxEdgeCount int
22 | }
23 |
24 | type Sketch struct {
25 | UserParams // embed for easier access
26 | source image.Image
27 | dc *gg.Context
28 | sourceWidth int
29 | sourceHeight int
30 | strokeSize float64
31 | initialStrokeSize float64
32 | }
33 |
34 | func NewSketch(source image.Image, userParams UserParams) *Sketch {
35 | s := &Sketch{UserParams: userParams}
36 | bounds := source.Bounds()
37 | s.sourceWidth, s.sourceHeight = bounds.Max.X, bounds.Max.Y
38 | s.initialStrokeSize = s.StrokeRatio * float64(s.DestWidth)
39 | s.strokeSize = s.initialStrokeSize
40 |
41 | canvas := gg.NewContext(s.DestWidth, s.DestHeight)
42 | canvas.SetColor(color.Black)
43 | canvas.DrawRectangle(0, 0, float64(s.DestWidth), float64(s.DestHeight))
44 | canvas.FillPreserve()
45 |
46 | s.source = source
47 | s.dc = canvas
48 | return s
49 | }
50 |
51 | func (s *Sketch) Update() {
52 | rndX := rand.Float64() * float64(s.sourceWidth)
53 | rndY := rand.Float64() * float64(s.sourceHeight)
54 | r, g, b := rgb255(s.source.At(int(rndX), int(rndY)))
55 |
56 | destX := rndX * float64(s.DestWidth) / float64(s.sourceWidth)
57 | destX += float64(randRange(s.StrokeJitter))
58 | destY := rndY * float64(s.DestHeight) / float64(s.sourceHeight)
59 | destY += float64(randRange(s.StrokeJitter))
60 | edges := s.MinEdgeCount + rand.Intn(s.MaxEdgeCount-s.MinEdgeCount+1)
61 |
62 | s.dc.SetRGBA255(r, g, b, int(s.InitialAlpha))
63 | s.dc.DrawRegularPolygon(edges, destX, destY, s.strokeSize, rand.Float64())
64 | s.dc.FillPreserve()
65 |
66 | if s.strokeSize <= s.StrokeInversionThreshold*s.initialStrokeSize {
67 | if (r+g+b)/3 < 128 {
68 | s.dc.SetRGBA255(255, 255, 255, int(s.InitialAlpha*2))
69 | } else {
70 | s.dc.SetRGBA255(0, 0, 0, int(s.InitialAlpha*2))
71 | }
72 | }
73 | s.dc.Stroke()
74 |
75 | s.strokeSize -= s.StrokeReduction * s.strokeSize
76 | s.InitialAlpha += s.AlphaIncrease
77 | }
78 |
79 | func (s *Sketch) Output() image.Image {
80 | return s.dc.Image()
81 | }
82 |
83 | func rgb255(c color.Color) (r, g, b int) {
84 | r0, g0, b0, _ := c.RGBA()
85 | return int(r0 / 257), int(g0 / 257), int(b0 / 257)
86 | }
87 |
88 | func randRange(max int) int {
89 | return -max + rand.Intn(2*max)
90 | }
91 |
--------------------------------------------------------------------------------
/wasm/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "image"
6 | _ "image/jpeg"
7 | "image/png"
8 | _ "image/png"
9 | "syscall/js"
10 |
11 | "github.com/preslavrachev/generative-art-in-go/sketch"
12 | )
13 |
14 | type app struct {
15 | console js.Value
16 | loadImageFunc js.Func
17 | startRenderingFunc js.Func
18 | inputBuffer []uint8
19 | sourceImg image.Image
20 | done chan struct{}
21 | }
22 |
23 | func newApp() *app {
24 | app := app{}
25 | app.setup()
26 |
27 | return &app
28 | }
29 |
30 | func (app *app) setup() {
31 | app.console = js.Global().Get("console")
32 | app.loadImageFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
33 | array := args[0]
34 | app.inputBuffer = make([]uint8, array.Get("byteLength").Int())
35 | js.CopyBytesToGo(app.inputBuffer, array)
36 |
37 | app.console.Call("log", array.Get("byteLength").Int())
38 |
39 | reader := bytes.NewReader(app.inputBuffer)
40 | var err error
41 | app.sourceImg, _, err = image.Decode(reader)
42 | if err != nil {
43 | app.console.Call("log", err.Error())
44 | return nil
45 | }
46 | return nil
47 | })
48 |
49 | app.startRenderingFunc = js.FuncOf(func(this js.Value, args []js.Value) interface{} {
50 | app.console.Call("log", "creating context")
51 | destWidth := app.sourceImg.Bounds().Dx()
52 | s := sketch.NewSketch(app.sourceImg, sketch.UserParams{
53 | StrokeRatio: 0.75,
54 | DestWidth: destWidth,
55 | DestHeight: app.sourceImg.Bounds().Dy(),
56 | InitialAlpha: 0.1,
57 | StrokeReduction: 0.002,
58 | AlphaIncrease: 0.06,
59 | StrokeInversionThreshold: 0.05,
60 | StrokeJitter: int(0.1 * float64(destWidth)),
61 | MinEdgeCount: 3,
62 | MaxEdgeCount: 4,
63 | })
64 | app.console.Call("log", "drawing starts")
65 | for i := 0; i < 5000; i++ {
66 | s.Update()
67 | if i%500 == 0 {
68 | app.updateImage(s.Output())
69 | }
70 | if i%100 == 0 {
71 | app.trackProgress(int(float64(i) / 5000 * 100))
72 | }
73 | }
74 |
75 | app.console.Call("log", "drawing done")
76 | app.trackProgress(100)
77 | return nil
78 | })
79 | }
80 |
81 | func (app *app) trackProgress(progress int) {
82 | js.Global().Call("trackProgress", progress)
83 | }
84 |
85 | func (app *app) updateImage(img image.Image) {
86 | buf := bytes.NewBuffer(make([]byte, 0))
87 | png.Encode(buf, img)
88 | dst := js.Global().Get("Uint8Array").New(len(buf.Bytes()))
89 | js.CopyBytesToJS(dst, buf.Bytes())
90 | js.Global().Call("displayImage", dst)
91 | }
92 |
93 | func main() {
94 | app := newApp()
95 | js.Global().Set("loadImage", app.loadImageFunc)
96 | js.Global().Set("startRendering", app.startRenderingFunc)
97 | <-app.done
98 | }
99 |
--------------------------------------------------------------------------------
/wasm/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
This is 44 | a simple experiment at using WebAssembly (WASM) for 45 | rendering a sketch from a source image. This demo is part of my 46 | upcoming book Generative Art in 49 | Go. 50 | Note: This 51 | is an 52 | experiment built for educational purposes. Don't expect 53 | production-grade performance! I can also not guarantee that it 54 | will work with all browsers. 55 |
56 |