├── .gitignore ├── README.md ├── Tiltfile ├── assets ├── button.gif ├── live-update.gif └── tilt-up.gif ├── base.dockerfile ├── filters ├── bounding-box │ ├── Dockerfile │ ├── Inconsolata-Bold.ttf │ ├── k8s.yaml │ └── main.go ├── color │ ├── Dockerfile │ ├── k8s.yaml │ └── main.go └── glitch │ ├── Dockerfile │ ├── k8s.yaml │ └── main.go ├── frontend ├── .babelrc ├── .dockerignore ├── Dockerfile ├── components │ ├── Button.js │ ├── Header.js │ ├── ImageDisplay.js │ ├── ImageSelect.js │ ├── UploadControl.js │ ├── apply.svg │ ├── color.js │ ├── drop.svg │ ├── mixer.svg │ ├── pixel-tilt-logo.svg │ ├── reset.svg │ ├── scan.svg │ ├── tilt-imagemark.svg │ └── tilt-logo.svg ├── k8s.yaml ├── package-lock.json ├── package.json ├── pages │ ├── _app.js │ ├── _document.js │ ├── api │ │ ├── image │ │ │ └── [name].js │ │ ├── list.js │ │ └── upload.js │ ├── index.js │ └── styles.css └── public │ ├── baby-bear.png │ ├── duck.png │ └── plane.png ├── go.mod ├── go.sum ├── muxer ├── Dockerfile ├── k8s.yaml └── main.go ├── object-detector └── k8s.yaml ├── pixeltilt.gif ├── render └── api │ └── api.go └── storage ├── Dockerfile ├── api └── api.go ├── client └── client.go ├── k8s.yaml └── main.go /.gitignore: -------------------------------------------------------------------------------- 1 | # frontend 2 | node_modules/ 3 | .pnp 4 | .pnp.js 5 | .next/ 6 | 7 | # testing 8 | coverage/ 9 | 10 | # production 11 | build/ 12 | 13 | # tilt 14 | tilt_modules/ 15 | 16 | # misc 17 | .DS_Store 18 | 19 | .env.local 20 | .env.development.local 21 | .env.test.local 22 | .env.production.local 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PixelTilt 2 | PixelTilt is a browser-based image editor. It is designed as demo project for [Tilt][tilt] and is comprised of several 3 | microservices deployed via Kubernetes. 4 | 5 | ![PixelTilt applying the glitch filter to an image](./pixeltilt.gif) 6 | 7 | The [`Tiltfile`](https://github.com/tilt-dev/pixeltilt/blob/master/Tiltfile) contains comments and links to relevant 8 | documentation. 9 | 10 | ## Getting Started 11 | If you don't already have Tilt installed, follow the [Tilt install][tilt-install] guide. 12 | > :information_source: The install guide also covers the prerequisites: Docker, `kubectl`, and a local Kubernetes development cluster 13 | 14 | Once you're ready, clone this repo and run `tilt up` from the main `pixeltilt` directory. 15 | 16 | ![tilt up for the PixelTilt project](./assets/tilt-up.gif) 17 | 18 | After some time, the resources should all become ready. If a resource fails, click on it to navigate to the logs and 19 | investigate. Once successful, try clicking the endpoint link for the `frontend` resource to start using the PixelTilt 20 | app! 21 | 22 | ## Showcase 23 | ### Live Update 24 | The `glitch` resource is configured to use [live update][live-update]. 25 | 26 | Try editing `filters/glitch/main.go` to use one of the commented out formulas instead and see what happens: 27 | ![Tilt live update on the glitch resource](./assets/live-update.gif) 28 | 29 | ### Custom Buttons 30 | The `storage` resource has a custom [UIButton][uibutton-ext] to make it easy to reset the application state. 31 | 32 | When clicked, a `curl` command accesses a special endpoint on the microservice to flush the data: 33 | ![Tilt custom button triggering a storage reset](./assets/button.gif) 34 | 35 | [ctlptl]: https://github.com/tilt-dev/ctlptl 36 | 37 | [live-update]: https://docs.tilt.dev/live_update_tutorial.html 38 | 39 | [tilt]: https://github.com/tilt-dev/tilt 40 | 41 | [tilt-install]: https://docs.tilt.dev/install.html 42 | 43 | [uibutton-ext]: https://github.com/tilt-dev/tilt-extensions/tree/master/uibutton 44 | -------------------------------------------------------------------------------- /Tiltfile: -------------------------------------------------------------------------------- 1 | # version_settings() enforces a minimum Tilt version 2 | # https://docs.tilt.dev/api.html#api.version_settings 3 | version_settings(constraint='>=0.22.1') 4 | 5 | # load() can be used to split your Tiltfile logic across multiple files 6 | # the special ext:// prefix loads the corresponding extension from 7 | # https://github.com/tilt-dev/tilt-extensions instead of a local file 8 | load('ext://restart_process', 'docker_build_with_restart') 9 | load('ext://uibutton', 'cmd_button') 10 | 11 | # k8s_yaml automatically creates resources in Tilt for the entities 12 | # and will inject any images referenced in the Tiltfile when deploying 13 | # https://docs.tilt.dev/api.html#api.k8s_yaml 14 | k8s_yaml([ 15 | 'filters/glitch/k8s.yaml', 16 | 'filters/color/k8s.yaml', 17 | 'filters/bounding-box/k8s.yaml', 18 | 'storage/k8s.yaml', 19 | 'muxer/k8s.yaml', 20 | 'object-detector/k8s.yaml', 21 | 'frontend/k8s.yaml', 22 | ]) 23 | 24 | # k8s_resource allows customization where necessary such as adding port forwards 25 | # https://docs.tilt.dev/api.html#api.k8s_resource 26 | k8s_resource("frontend", port_forwards="3000", labels=["frontend"]) 27 | k8s_resource("storage", port_forwards="8080", labels=["infra"]) 28 | k8s_resource("max-object-detector", labels=["infra"], new_name="object-detector") 29 | k8s_resource("glitch", labels=["backend"]) 30 | k8s_resource("color", labels=["backend"]) 31 | k8s_resource("bounding-box", labels=["backend"]) 32 | k8s_resource("muxer", labels=["backend"]) 33 | 34 | # cmd_button extension adds custom buttons to a resource to execute tasks on demand 35 | # https://github.com/tilt-dev/tilt-extensions/tree/master/uibutton 36 | cmd_button( 37 | name='flush-storage', 38 | resource='storage', 39 | argv=['curl', '-s', 'http://localhost:8080/flush'], 40 | text='Flush DB', 41 | icon_name='delete' 42 | ) 43 | 44 | 45 | # frontend is a next.js app which has built-in support for hot reload 46 | # live_update only syncs changed files to the correct place for it to pick up 47 | # https://docs.tilt.dev/api.html#api.docker_build 48 | # https://docs.tilt.dev/live_update_reference.html 49 | docker_build( 50 | "frontend", 51 | context="./frontend", 52 | live_update=[ 53 | sync('./frontend', '/usr/src/app') 54 | ] 55 | ) 56 | 57 | # the various go services share a base image to avoid re-downloading the same 58 | # dependencies numerous times - `only` is used to prevent unnecessary rebuilds 59 | # https://docs.tilt.dev/api.html#api.docker_build 60 | docker_build( 61 | "pixeltilt-base", 62 | context=".", 63 | dockerfile="base.dockerfile", 64 | only=['go.mod', 'go.sum'] 65 | ) 66 | 67 | # docker_build_with_restart automatically restarts the process defined by 68 | # `entrypoint` argument after completing the live_update (which syncs .go 69 | # source files and recompiles inside the container) 70 | # https://github.com/tilt-dev/tilt-extensions/tree/master/restart_process 71 | # https://docs.tilt.dev/live_update_reference.html 72 | docker_build_with_restart( 73 | "glitch", 74 | context=".", 75 | dockerfile="filters/glitch/Dockerfile", 76 | only=['filters/glitch', 'render/api'], 77 | entrypoint='/usr/local/bin/glitch', 78 | live_update=[ 79 | sync('filters/glitch', '/app/glitch'), 80 | sync('render/api', '/app/render/api'), 81 | run('go build -mod=vendor -o /usr/local/bin/glitch ./glitch') 82 | ] 83 | ) 84 | 85 | # for the remainder of the services, plain docker_build is used - these 86 | # services are changed less frequently, so live_update is less important 87 | # any of them can be adapted to use live_update by using "glitch" as an 88 | # example above! 89 | docker_build( 90 | "muxer", 91 | context=".", 92 | dockerfile="muxer/Dockerfile", 93 | only=['muxer', 'render/api', 'storage/api', 'storage/client'] 94 | ) 95 | 96 | docker_build( 97 | "color", 98 | context=".", 99 | dockerfile="filters/color/Dockerfile", 100 | only=['filters/color', 'render/api'] 101 | ) 102 | 103 | docker_build( 104 | "bounding-box", 105 | context=".", 106 | dockerfile="filters/bounding-box/Dockerfile", 107 | only=['filters/bounding-box', 'render/api'] 108 | ) 109 | 110 | docker_build( 111 | "storage", 112 | context=".", 113 | dockerfile="storage/Dockerfile", 114 | only=['storage'], 115 | entrypoint='/usr/local/bin/storage' 116 | ) 117 | -------------------------------------------------------------------------------- /assets/button.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/assets/button.gif -------------------------------------------------------------------------------- /assets/live-update.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/assets/live-update.gif -------------------------------------------------------------------------------- /assets/tilt-up.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/assets/tilt-up.gif -------------------------------------------------------------------------------- /base.dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.16-alpine 2 | 3 | WORKDIR /app 4 | 5 | ENV GOMODCACHE=/cache/gomod 6 | ENV GOCACHE=/cache/gobuild 7 | 8 | COPY go.mod go.sum ./ 9 | RUN --mount=type=cache,target=/cache/gomod \ 10 | go mod download 11 | -------------------------------------------------------------------------------- /filters/bounding-box/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM pixeltilt-base 2 | 3 | COPY ../../render/api render/api/ 4 | COPY filters/bounding-box/ rectangler/ 5 | 6 | RUN --mount=type=cache,target=/cache/gomod \ 7 | --mount=type=cache,target=/cache/gobuild,sharing=locked \ 8 | go mod vendor && \ 9 | go build -mod=vendor -o /usr/local/bin/rectangler ./rectangler 10 | 11 | CMD ["/usr/local/bin/rectangler"] 12 | -------------------------------------------------------------------------------- /filters/bounding-box/Inconsolata-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/filters/bounding-box/Inconsolata-Bold.ttf -------------------------------------------------------------------------------- /filters/bounding-box/k8s.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: bounding-box 6 | labels: 7 | app: bounding-box 8 | spec: 9 | selector: 10 | matchLabels: 11 | app: bounding-box 12 | template: 13 | metadata: 14 | labels: 15 | app: bounding-box 16 | spec: 17 | containers: 18 | - name: bounding-box 19 | image: bounding-box 20 | ports: 21 | - containerPort: 8080 22 | --- 23 | apiVersion: v1 24 | kind: Service 25 | metadata: 26 | name: bounding-box 27 | labels: 28 | app: bounding-box 29 | spec: 30 | ports: 31 | - port: 8080 32 | targetPort: 8080 33 | protocol: TCP 34 | selector: 35 | app: bounding-box 36 | -------------------------------------------------------------------------------- /filters/bounding-box/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "image" 8 | "image/draw" 9 | "image/png" 10 | "io/ioutil" 11 | "log" 12 | "mime/multipart" 13 | "net/http" 14 | "os" 15 | "strings" 16 | 17 | "github.com/fogleman/gg" 18 | "github.com/golang/freetype/truetype" 19 | "github.com/pkg/errors" 20 | "github.com/tilt-dev/pixeltilt/render/api" 21 | ) 22 | 23 | type data struct { 24 | Status string `json:"status"` 25 | Predictions []struct { 26 | LabelID string `json:"label_id"` 27 | Label string `json:"label"` 28 | Probability float64 `json:"probability"` 29 | DetectionBox []float64 `json:"detection_box"` 30 | } `json:"predictions"` 31 | } 32 | 33 | func main() { 34 | fmt.Println("Rectangler running!") 35 | port := "8080" 36 | if len(os.Args) > 1 { 37 | port = os.Args[1] 38 | } 39 | 40 | HandleWithNoSubpath("/", api.HttpRenderHandler(render)) 41 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil)) 42 | } 43 | 44 | func HandleWithNoSubpath(path string, f func(http.ResponseWriter, *http.Request)) { 45 | handler := func(w http.ResponseWriter, req *http.Request) { 46 | if req.URL.Path != path { 47 | http.NotFound(w, req) 48 | return 49 | } 50 | f(w, req) 51 | } 52 | 53 | http.HandleFunc(path, handler) 54 | } 55 | 56 | func render(req api.RenderRequest) (api.RenderReply, error) { 57 | // Run detection on original image 58 | detected, err := sendPostRequest("http://max-object-detector:5000/model/predict?threshold=0.7", "image", req.OriginalImage) 59 | if err != nil { 60 | return api.RenderReply{}, errors.Wrap(err, "max-object-detector") 61 | } 62 | 63 | data := data{} 64 | err = json.Unmarshal(detected, &data) 65 | if err != nil { 66 | return api.RenderReply{}, errors.Wrap(err, "max-object-detector") 67 | } 68 | 69 | input, err := png.Decode(bytes.NewReader(req.Image)) 70 | if err != nil { 71 | return api.RenderReply{}, errors.Wrap(err, "decoding image") 72 | } 73 | 74 | overlayImg, err := rectangler(data, input.Bounds().Size()) 75 | if err != nil { 76 | return api.RenderReply{}, errors.Wrap(err, "rectangling") 77 | } 78 | 79 | originalImage, err := png.Decode(bytes.NewReader(req.Image)) 80 | if err != nil { 81 | return api.RenderReply{}, errors.Wrap(err, "decoding image") 82 | } 83 | 84 | merged := merge(originalImage, overlayImg) 85 | var buf bytes.Buffer 86 | err = png.Encode(&buf, merged) 87 | if err != nil { 88 | return api.RenderReply{}, errors.Wrap(err, "encoding image") 89 | } 90 | 91 | return api.RenderReply{Image: buf.Bytes()}, nil 92 | } 93 | 94 | // type data struct { 95 | // Status string `json:"status"e` 96 | // Predictions []struct { 97 | // LabelID string `json:"label_id"` 98 | // Label string `json:"label"` 99 | // Probability int `json:"probability"` 100 | // DetectionBox []int `json:"detection_box"` 101 | // } `json:"predictions"` 102 | // } 103 | 104 | // [ymin, xmin, ymax, xmax] 105 | func rectangler(data data, pt image.Point) (*image.RGBA, error) { 106 | pic := gg.NewContext(pt.X, pt.Y) 107 | pic.SetRGBA(0, 0, 0, 0) 108 | pic.Clear() 109 | w, h := float64(pt.X), float64(pt.Y) 110 | 111 | fontfile, err := ioutil.ReadFile("rectangler/Inconsolata-Bold.ttf") 112 | if err != nil { 113 | return nil, err 114 | } 115 | font, err := truetype.Parse(fontfile) 116 | if err != nil { 117 | return nil, err 118 | } 119 | fontSize := (w + h) / 70 120 | face := truetype.NewFace(font, &truetype.Options{Size: fontSize}) 121 | pic.SetFontFace(face) 122 | 123 | for i := 0; i < len(data.Predictions); i++ { 124 | values := data.Predictions[i].DetectionBox 125 | ymin, xmin, ymax, xmax := values[0], values[1], values[2], values[3] 126 | pic.SetRGBA(1, 1, 1, 1) 127 | pic.SetLineWidth((w + h) / 500) 128 | 129 | pic.DrawRectangle(xmin*w, ymin*h, xmax*w-xmin*w, ymax*h-ymin*h) 130 | pic.Stroke() 131 | 132 | object := fmt.Sprintf("OBJECT: %s", strings.ToUpper(data.Predictions[i].Label)) 133 | pic.DrawString(object, xmin*w, ymin*h-fontSize/2-fontSize*1.2) 134 | probability := fmt.Sprintf("PROBABILITY: %f", data.Predictions[i].Probability) 135 | pic.DrawString(probability, xmin*w, ymin*h-fontSize/2) 136 | } 137 | 138 | return pic.Image().(*image.RGBA), nil 139 | } 140 | 141 | func sendPostRequest(url string, name string, image []byte) ([]byte, error) { 142 | body := &bytes.Buffer{} 143 | writer := multipart.NewWriter(body) 144 | part, err := writer.CreateFormFile("image", name) 145 | if err != nil { 146 | return nil, err 147 | } 148 | 149 | _, err = part.Write(image) 150 | if err != nil { 151 | return nil, err 152 | } 153 | err = writer.Close() 154 | if err != nil { 155 | return nil, err 156 | } 157 | 158 | request, err := http.NewRequest("POST", url, body) 159 | if err != nil { 160 | return nil, err 161 | } 162 | 163 | request.Header.Add("Content-Type", writer.FormDataContentType()) 164 | request.Header.Add("accept", "application/json") 165 | client := &http.Client{} 166 | 167 | response, err := client.Do(request) 168 | if err != nil { 169 | return nil, err 170 | } 171 | defer response.Body.Close() 172 | 173 | content, err := ioutil.ReadAll(response.Body) 174 | if err != nil { 175 | return nil, err 176 | } 177 | 178 | return content, nil 179 | } 180 | 181 | func merge(img1, img2 image.Image) *image.RGBA { 182 | img3 := image.NewRGBA(img1.Bounds()) 183 | draw.Draw(img3, img3.Bounds(), img1, image.ZP, draw.Src) 184 | draw.Draw(img3, img3.Bounds(), img2, image.ZP, draw.Over) 185 | return img3 186 | } 187 | -------------------------------------------------------------------------------- /filters/color/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM pixeltilt-base 2 | 3 | COPY ../../render/api render/api/ 4 | COPY filters/color/ red/ 5 | 6 | RUN --mount=type=cache,target=/cache/gomod \ 7 | --mount=type=cache,target=/cache/gobuild,sharing=locked \ 8 | go mod vendor && \ 9 | go build -mod=vendor -o /usr/local/bin/red ./red 10 | 11 | CMD ["/usr/local/bin/red"] 12 | -------------------------------------------------------------------------------- /filters/color/k8s.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: color 6 | labels: 7 | app: color 8 | spec: 9 | selector: 10 | matchLabels: 11 | app: color 12 | template: 13 | metadata: 14 | labels: 15 | app: color 16 | spec: 17 | containers: 18 | - name: color 19 | image: color 20 | ports: 21 | - containerPort: 8080 22 | --- 23 | apiVersion: v1 24 | kind: Service 25 | metadata: 26 | name: color 27 | labels: 28 | app: color 29 | spec: 30 | ports: 31 | - port: 8080 32 | targetPort: 8080 33 | protocol: TCP 34 | selector: 35 | app: color 36 | -------------------------------------------------------------------------------- /filters/color/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "image" 7 | "image/color" 8 | "image/png" 9 | "log" 10 | "net/http" 11 | "os" 12 | 13 | "github.com/tilt-dev/pixeltilt/render/api" 14 | ) 15 | 16 | func main() { 17 | fmt.Println("Red running!") 18 | port := "8080" 19 | if len(os.Args) > 1 { 20 | port = os.Args[1] 21 | } 22 | 23 | http.HandleFunc("/", api.HttpRenderHandler(render)) 24 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil)) 25 | } 26 | 27 | func render(req api.RenderRequest) (api.RenderReply, error) { 28 | pic, err := png.Decode(bytes.NewReader(req.Image)) 29 | if err != nil { 30 | return api.RenderReply{}, err 31 | } 32 | fmt.Println("red decoded ok") 33 | 34 | newImg := red(pic) 35 | var buf bytes.Buffer 36 | err = png.Encode(&buf, newImg) 37 | if err != nil { 38 | return api.RenderReply{}, err 39 | } 40 | resp := api.RenderReply{ 41 | Image: buf.Bytes(), 42 | } 43 | 44 | return resp, nil 45 | } 46 | 47 | func red(pic image.Image) *image.RGBA { 48 | picSize := pic.Bounds().Size() 49 | newPic := image.NewRGBA(image.Rect(0, 0, picSize.X, picSize.Y)) 50 | 51 | for x := 0; x < picSize.X; x++ { 52 | for y := 0; y < picSize.Y; y++ { 53 | pixel := color.RGBAModel.Convert(pic.At(x, y)).(color.RGBA) 54 | new := color.RGBA{ 55 | // R: uint8(float64(pixel.R) * 0.8), 56 | // G: 0, 57 | R: 0, 58 | G: uint8(float64(pixel.G) * 0.8), 59 | B: 0, 60 | A: pixel.A, 61 | } 62 | newPic.Set(x, y, new) 63 | } 64 | } 65 | 66 | return newPic 67 | } 68 | -------------------------------------------------------------------------------- /filters/glitch/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM pixeltilt-base 2 | 3 | COPY ../../render/api render/api/ 4 | COPY filters/glitch/ glitch/ 5 | 6 | RUN --mount=type=cache,target=/cache/gomod \ 7 | --mount=type=cache,target=/cache/gobuild,sharing=locked \ 8 | go mod vendor && \ 9 | go build -mod=vendor -o /usr/local/bin/glitch ./glitch 10 | 11 | CMD ["/usr/local/bin/glitch"] 12 | -------------------------------------------------------------------------------- /filters/glitch/k8s.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: glitch 6 | labels: 7 | app: glitch 8 | spec: 9 | selector: 10 | matchLabels: 11 | app: glitch 12 | template: 13 | metadata: 14 | labels: 15 | app: glitch 16 | spec: 17 | containers: 18 | - name: glitch 19 | image: glitch 20 | ports: 21 | - containerPort: 8080 22 | --- 23 | apiVersion: v1 24 | kind: Service 25 | metadata: 26 | name: glitch 27 | labels: 28 | app: glitch 29 | spec: 30 | ports: 31 | - port: 8080 32 | targetPort: 8080 33 | protocol: TCP 34 | selector: 35 | app: glitch 36 | -------------------------------------------------------------------------------- /filters/glitch/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | _ "image/jpeg" 7 | "image/png" 8 | _ "image/png" 9 | "log" 10 | "net/http" 11 | "os" 12 | 13 | "github.com/pkg/errors" 14 | "github.com/sug0/go-glitch" 15 | 16 | "github.com/tilt-dev/pixeltilt/render/api" 17 | ) 18 | 19 | // glitch formulas from: https://github.com/sug0/go-glitch/blob/master/res/cool.txt 20 | 21 | // const glitchExprStr = "128 & (c - ((c - 150 + s) > 5 < s))" 22 | // const glitchExprStr = "(c & (c ^ 55)) + 25" 23 | // const glitchExprStr = "r | ((255 - (Y > 2) : ((r | c) < 2)) - 140)" 24 | // const glitchExprStr = "(Y | (c > 1)) ^ 128" 25 | // const glitchExprStr = "e ^ c - (e - 55)" 26 | // const glitchExprStr = "86 ^ ((R&c) > ((G&c) - ((G&c) ^ ((G&c) / (175 : (x < ((B&c) + ((G&c) + r))))))))" 27 | // const glitchExprStr = "(Y - ((G & 55)|(R - 25))) ^ 25" 28 | // const glitchExprStr = "Y : (146 - (e | (15 ? 185)))" 29 | // const glitchExprStr = "Y & (206 / (e & (Y / r)))" 30 | // const glitchExprStr = "179 ^ (Y % (x | (s : (209 : (Y % (G - (r # r)))))))" 31 | // const glitchExprStr = "36 - (67 | (Y - (G | R)))" 32 | // const glitchExprStr = "128 & (c - (s - 255) + s) : (s ^ (c ^ 255)) + 25" 33 | // const glitchExprStr = "((Y/s) + c < 1) + (x*x*y) % (c ^ ((y*x - y*y) < (y > 5)))" 34 | // const glitchExprStr = "(c&x-y)/(s | x*y) - ((y+c)/x < 2)" 35 | // const glitchExprStr = "(c & (s ^ 55)) + (25 > s)" 36 | // const glitchExprStr = "128 ^ ((r ^ 15) | (c - (s - (r * 255))))" 37 | // const glitchExprStr = "(r | c) < 3" 38 | // const glitchExprStr = "(r - Y) | (((r | ((c | Y) > 2)) < 2) + (Y % 255))" 39 | // const glitchExprStr = "((Y | ((((108 - Y) * s) > c + Y + s) ^ 5 + 25)) * (r > 5)) : ((c < 1) | Y)" 40 | // const glitchExprStr = "(s & c) | (((Y + (r - 55)) ^ s) > 10) < 10 - ((128 - c) | Y)" 41 | // const glitchExprStr = "(s & c) | (((Y + (r - 55)) ^ s) > 15) < 10 - ((16 - Y) | c)" 42 | // const glitchExprStr = "(r - ((Y % 15) : (r < 5)) > 10) | (s - c)" 43 | // const glitchExprStr = "c|((e - s + x - y) / N) - (R&c)|(B&c) + ((G&c)*(G&c)*(G&c))/(x*x*x) - (s & (R&c))" 44 | // const glitchExprStr = "((x*x*x % y) + (c - s) + r) > 1" 45 | // const glitchExprStr = "((x - y) # (y - x) ? (R&c)|(G&c)) - ((255 - (80 ? c)) & c)" 46 | // const glitchExprStr = "((128 ? x) ^ (128 ? y)) : ((y - x) # (x - y) ? (Y-c)) - ((255 - (80 ? s)) & y - (R&c)|(G&c))" 47 | // const glitchExprStr = "((s - x) # (e - s)) ? ((128 - x) ^ (y - c)) ? (128 - Y)" 48 | // const glitchExprStr = "c & (((230 - (130 ? Y)) : (s - ((G&c)|(R&c))) + s) - s) + s" 49 | // const glitchExprStr = "y ^ ((G&c) + ((B&c) | ((B&c) * (y | (166 ? ((G&c) % s))))))" 50 | // const glitchExprStr = "33 + (e - (s > ((R&c) : (Y + (x ? (204 < 243))))))" 51 | // const glitchExprStr = "(e | R) - ((c & G) - (Y & G))" 52 | // const glitchExprStr = "b ^ (233 < (B > (r # b)))" 53 | // const glitchExprStr = "Y ^ (106 & (Y ? (r < G)))" 54 | // const glitchExprStr = "182 + (x + (s - (y % (15 & (r ? (e + (s : (76 - (y # (r * (r ? (r | (y % (r ? (195 ? (R - (123 > (b : N)))))))))))))))"))) 55 | // const glitchExprStr = "b ^ (164 - (b < (G # (b ^ (e % r)))))" 56 | // const glitchExprStr = "c | (s < (c & (r ? (B | (e - (Y < (Y ^ Y)))))))" 57 | 58 | const glitchExprStr = "b ^ (r | (s : (x # B)))" 59 | 60 | // const glitchExprStr = "G / (b / (N > (110 > s)))" 61 | // const glitchExprStr = "29 ? (Y & (c ^ (s > c)))" 62 | // const glitchExprStr = "((c|Y) < 3) @ ((H-L+10)|(((c < 1) @ ((s/x) * y)) ^ (c - L) | (Y - L)))" 63 | // const glitchExprStr = "(c<(s%4)>1)@(L+25)-25" 64 | // const glitchExprStr = "(c&(R-Y)&25)^(Y|((Y:h)-25))@(Y-s)" 65 | // const glitchExprStr = "255 ^ ((H ^ L) - s) ? ((c & (R @ 246)) | (c & (G @ 155)) | (c & (B @ 255)))" 66 | // const glitchExprStr = "e@((H-((c < 2) - (N@r)%128))+(L&R)-(H-G))-((e&r)/16)" 67 | // const glitchExprStr = "((x*x*x*y*y*y) < s) + (c > 4)" 68 | // const glitchExprStr = "c & (Y - x*y)" 69 | // const glitchExprStr = "c * (x ^ 5)" 70 | // const glitchExprStr = "((y*s - y*s) & (Y | c)) ^ (x|c)" 71 | // const glitchExprStr = "((y*s - y*s < s) & (Y | c)) ^ (x|c) > s" 72 | // const glitchExprStr = "(r - y) | ((55 - x*3) ^ 25) : (((y*s - y*s < s) & (Y | c)) ^ ((255-y)+x|c) > s)" 73 | // const glitchExprStr = "128 + ((r > 2 < 1) : ((x|y) ^ 55)) % 50 - c + y" 74 | // const glitchExprStr = "c & ((x+y*y*y+r) : (s + c % 15) - x*x)" 75 | // const glitchExprStr = "c ^ ((x ? Y) ? (255 - e))" 76 | // const glitchExprStr = "((Y - x) ? (c - s)) ^ (0 - (x + y) : (0 - (y - x)))" 77 | // const glitchExprStr = "((s + (R&c)) - c) & (0 - (x ? (0 - (x - x*y) : y)) - (1 : (y ^ x))) - (c + s)" 78 | // const glitchExprStr = "(c @ (s & G)) ^ (c - L)" 79 | // const glitchExprStr = "L ^ H > H < L % R" 80 | // const glitchExprStr = "(c ^ (L ^ H)) % (R > c) : (( L ^ H < R > c) % (5 ^ R))" 81 | // const glitchExprStr = "(Y - c)|(r - s/2) + (61 & (R - (r % Y)))" 82 | // const glitchExprStr = "(c-(s>2))/16*((Y-H)@128)" 83 | // const glitchExprStr = "((y/(x*x) ^ s) + x*s) > 2 + c" 84 | // const glitchExprStr = "Y < (s # (e * (y & (x : (x & (s % (r + (s ? c))))))))" 85 | // const glitchExprStr = "120 : (Y & (N | (s & (B & (e # (Y + (Y : R)))))))" 86 | // const glitchExprStr = "Y | (34 % (194 < (e < G)))" 87 | // const glitchExprStr = "r + (61 & (R - (r % e)))" 88 | // const glitchExprStr = "Y ^ (s & (60 > (y < (239 ? (c / b)))))" 89 | // const glitchExprStr = "#((1 - y : x) ^ (1 - y & x)) & (0 - (x*x*x % y))" 90 | // const glitchExprStr = "(s ^ (1 - x : y)) & ((((e|(G&c)) - r)|N < 2) > 1)" 91 | // const glitchExprStr = "((1 - y : x) | (1 - x : y)) - c" 92 | // const glitchExprStr = "(R&c)|(G&c) % (((1 - y : x) ^ (1 - x : y)) - c)" 93 | // const glitchExprStr = "100 + ((N & 25) ^ (1 - x : y)) + (75 & Y)" 94 | // const glitchExprStr = "((G&c) ^ (e - (R&c))) % ((N & 25) ^ (e - x : y)) + (75 & Y)" 95 | // const glitchExprStr = "(Y : c) ^ ((c + 50) & (0 - (y ? (0 - x : y)) & (y : x)))" 96 | // const glitchExprStr = "(255 - (140 ? c)) & c" 97 | // const glitchExprStr = "(H-L)|b" 98 | // const glitchExprStr = "L ^ H" 99 | // const glitchExprStr = "(c ^ (L ^ H)) % (R > c) : ( L ^ H < R > c)" 100 | // const glitchExprStr = "(r > (H ^ L) % b) : (H % L)" 101 | // const glitchExprStr = "((H + (s/L) - L) & R) | ((G / (b / (N > (110 > s)))) : R)" 102 | // const glitchExprStr = "b ^ (r | (s : (x # B)))" 103 | 104 | var glitchExpr *glitch.Expression 105 | 106 | func main() { 107 | fmt.Println("Glitch running!") 108 | port := "8080" 109 | if len(os.Args) > 1 { 110 | port = os.Args[1] 111 | } 112 | 113 | var err error 114 | glitchExpr, err = glitch.CompileExpression(glitchExprStr) 115 | if err != nil { 116 | log.Fatal(err) 117 | } 118 | 119 | HandleWithNoSubpath("/", api.HttpRenderHandler(render)) 120 | http.HandleFunc("/set_expr", setExpr) 121 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil)) 122 | } 123 | 124 | func HandleWithNoSubpath(path string, f func(http.ResponseWriter, *http.Request)) { 125 | handler := func(w http.ResponseWriter, req *http.Request) { 126 | if req.URL.Path != path { 127 | http.NotFound(w, req) 128 | return 129 | } 130 | f(w, req) 131 | } 132 | 133 | http.HandleFunc(path, handler) 134 | } 135 | 136 | // just a dumb form to let people play around with different expressions without restarting 137 | func setExpr(w http.ResponseWriter, r *http.Request) { 138 | w.Header().Set("Content-Type", "text/html") 139 | _, err := w.Write([]byte(` 140 |
141 | expr: 142 | 143 |
144 | `)) 145 | if err != nil { 146 | log.Printf("error writing response: %v\n", err) 147 | return 148 | } 149 | 150 | err = r.ParseForm() 151 | if err != nil { 152 | http.Error(w, fmt.Sprintf("error parsing request: %v", err), http.StatusBadRequest) 153 | return 154 | } 155 | 156 | expr := r.PostForm.Get("expr") 157 | if expr != "" { 158 | g, err := glitch.CompileExpression(expr) 159 | if err != nil { 160 | _, err := w.Write([]byte(fmt.Sprintf("
Invalid expression: %s
", err.Error()))) 161 | if err != nil { 162 | log.Printf("error writing response: %v\n", err) 163 | return 164 | } 165 | } 166 | glitchExpr = g 167 | log.Printf("changed expression to %s\n", expr) 168 | _, err = w.Write([]byte(fmt.Sprintf("
Expression set to %s
", expr))) 169 | if err != nil { 170 | log.Printf("error writing response: %v\n", err) 171 | return 172 | } 173 | } 174 | } 175 | 176 | func render(req api.RenderRequest) (api.RenderReply, error) { 177 | input, err := png.Decode(bytes.NewReader(req.Image)) 178 | if err != nil { 179 | // return api.RenderReply{}, errors.Wrap(err, "decoding image") 180 | fmt.Println("Glitch went boom!") 181 | os.Exit(1) 182 | } 183 | 184 | output, err := glitchExpr.JumblePixels(input) 185 | if err != nil { 186 | return api.RenderReply{}, errors.Wrap(err, "glitching image") 187 | } 188 | 189 | var buf bytes.Buffer 190 | err = png.Encode(&buf, output) 191 | if err != nil { 192 | return api.RenderReply{}, errors.Wrap(err, "encoding image") 193 | } 194 | 195 | return api.RenderReply{Image: buf.Bytes()}, nil 196 | } 197 | -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "next/babel" 4 | ], 5 | "plugins": [ 6 | "inline-react-svg", 7 | [ 8 | "styled-components", 9 | { 10 | "ssr": true, 11 | "displayName": true, 12 | "preprocess": false 13 | } 14 | ] 15 | ] 16 | } -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | build 2 | .next 3 | node_modules -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine3.14 2 | 3 | WORKDIR /usr/src/app 4 | 5 | ADD package.json package-lock.json ./ 6 | RUN npm install 7 | 8 | ADD . . 9 | 10 | EXPOSE 3000 11 | CMD ["npm", "run", "dev"] 12 | -------------------------------------------------------------------------------- /frontend/components/Button.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import color from "./color"; 3 | 4 | let ButtonRoot = styled.button` 5 | display: flex; 6 | border: 1px solid ${color.red}; 7 | color: ${color.red}; 8 | box-sizing: border-box; 9 | border-radius: 4px; 10 | padding: 0 8px; 11 | margin: 0 8px 0 0; 12 | align-items: center; 13 | justify-content: space-between; 14 | height: 30px; 15 | background-color: inherit; 16 | font-size: 10px; 17 | line-height: 14px; 18 | font-family: inherit; 19 | cursor: pointer; 20 | transition: background-color 300ms ease, color 300ms ease; 21 | box-sizing: content-box; 22 | 23 | &.is-toggle:hover { 24 | padding: 1px 9px; 25 | margin-left: -1px; 26 | margin-right: 7px !important; 27 | } 28 | 29 | &[disabled], 30 | &.is-green[disabled], 31 | &.is-yellow[disabled] { 32 | pointer-events: none; 33 | border-color: ${color.gray}; 34 | color: ${color.gray}; 35 | } 36 | &[disabled] path, 37 | &.is-green[disabled] path, 38 | &.is-yellow[disabled] path { 39 | fill: ${color.gray}; 40 | } 41 | 42 | & > svg { 43 | width: 14px; 44 | height: 14px; 45 | margin-right: 7px; 46 | } 47 | 48 | & path { 49 | transition: fill 300ms ease; 50 | fill: ${color.red}; 51 | } 52 | 53 | &:not(.is-toggle):hover { 54 | background-color: ${color.red}; 55 | color: ${color.grayDark}; 56 | } 57 | &:not(.is-toggle):hover path { 58 | fill: ${color.grayDark}; 59 | } 60 | 61 | &.is-yellow { 62 | border-color: ${color.yellow}; 63 | color: ${color.yellow}; 64 | } 65 | &.is-yellow path { 66 | fill: ${color.yellow}; 67 | } 68 | &.is-yellow:hover { 69 | color: ${color.grayDark}; 70 | background-color: ${color.yellow}; 71 | } 72 | &.is-yellow:hover path { 73 | fill: ${color.grayDark}; 74 | } 75 | 76 | &.is-green { 77 | border-color: ${color.green}; 78 | color: ${color.green}; 79 | } 80 | &.is-green path { 81 | fill: ${color.green}; 82 | } 83 | &.is-green:hover { 84 | color: ${color.grayDark}; 85 | background-color: ${color.green}; 86 | } 87 | &.is-green:hover path { 88 | fill: ${color.grayDark}; 89 | } 90 | 91 | &.is-selected { 92 | background-color: ${color.red}; 93 | color: ${color.grayDark}; 94 | } 95 | &.is-selected.is-yellow { 96 | background-color: ${color.yellow}; 97 | } 98 | &.is-selected.is-green { 99 | background-color: ${color.green}; 100 | } 101 | &.is-selected path { 102 | fill: ${color.grayDark}; 103 | } 104 | `; 105 | 106 | function Button(props) { 107 | let onClick = props.onClick; 108 | let name = props.name; 109 | let extraClasses = []; 110 | if (props.selected) { 111 | extraClasses.push("is-selected"); 112 | } 113 | if (props.isToggle) { 114 | extraClasses.push("is-toggle"); 115 | } 116 | if (props.isGreen) { 117 | extraClasses.push("is-green"); 118 | } 119 | if (props.isYellow) { 120 | extraClasses.push("is-yellow"); 121 | } 122 | let disabled = props.disabled; 123 | return ( 124 | 131 | {props.children} 132 | 133 | ); 134 | } 135 | 136 | export default Button; 137 | -------------------------------------------------------------------------------- /frontend/components/Header.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import {ReactComponent as PixelTiltLogoSvg} from "./pixel-tilt-logo.svg"; 3 | import {ReactComponent as TiltLogoSvg} from "./tilt-logo.svg"; 4 | import {ReactComponent as DropSvg} from "./drop.svg"; 5 | import {ReactComponent as MixerSvg} from "./mixer.svg"; 6 | import {ReactComponent as ScanSvg} from "./scan.svg"; 7 | import {ReactComponent as ResetSvg} from "./reset.svg"; 8 | import {ReactComponent as ApplySvg} from "./apply.svg"; 9 | import Button from "./Button"; 10 | import color from "./color"; 11 | 12 | let HeaderRoot = styled.header` 13 | color: ${color.gray}; 14 | box-sizing: border-box; 15 | `; 16 | 17 | let HeaderControls = styled.div` 18 | display: flex; 19 | align-items: center; 20 | min-height: 80px; 21 | background-color: ${color.grayDark}; 22 | box-sizing: border-box; 23 | justify-content: space-between; 24 | `; 25 | 26 | let HeaderPixelLogoCell = styled.a` 27 | display: flex; 28 | padding-left: 72px; 29 | width: 240px; 30 | box-sizing: border-box; 31 | `; 32 | 33 | let HeaderTiltLogoCell = styled.a` 34 | padding-right: 72px; 35 | display: flex; 36 | flex-direction: row-reverse; 37 | width: 240px; 38 | box-sizing: border-box; 39 | color: ${color.gray} !important; 40 | text-decoration: none !important; 41 | `; 42 | 43 | let TiltBrandText = styled.div` 44 | font-family: "Fira Code", sans-serif; 45 | font-size: 9px; 46 | line-height: 13px; 47 | text-align: right; 48 | width: 80px; 49 | `; 50 | 51 | let HeaderStatus = styled.div` 52 | display: flex; 53 | justify-content: center; 54 | align-items: center; 55 | width: 100%; 56 | min-height: 40px; 57 | background-color: ${color.grayLight}; 58 | box-sizing: border-box; 59 | `; 60 | 61 | let HeaderStatusMessage = styled.div` 62 | max-width: 650px; 63 | text-align: left; 64 | flex-grow: 1; 65 | `; 66 | 67 | let HeaderButtonRow = styled.div` 68 | display: flex; 69 | max-width: 650px; 70 | justify-content: space-between; 71 | align-items: center; 72 | height: 40px; 73 | flex-grow: 3; 74 | `; 75 | 76 | let HeaderButtonInnerRow = styled.div` 77 | display: flex; 78 | align-items: center; 79 | height: 40px; 80 | `; 81 | 82 | let LogoLink = styled.a` 83 | display: flex; 84 | `; 85 | 86 | const Header = props => { 87 | const filters = props.filters; 88 | const reset = props.reset; 89 | const apply = props.apply; 90 | const clearAndSetCheckedItems = props.clearAndSetCheckedItems; 91 | const checkedItems = props.checkedItems; 92 | const statusMessage = props.statusMessage; 93 | const hasFileSelection = props.hasFileSelection; 94 | const hasFilterSelection = Object.values(checkedItems).some(v => v); 95 | 96 | const handleChangeFilter = event => { 97 | let target = event.currentTarget; 98 | let newCheckedItems = { ...checkedItems }; 99 | newCheckedItems[target.name] = !checkedItems[target.name]; 100 | clearAndSetCheckedItems(newCheckedItems); 101 | }; 102 | 103 | let filterEls = filters.map(item => { 104 | let name = "filter_" + item.label.toLowerCase(); 105 | let selected = checkedItems[name]; 106 | let label = item.label; 107 | let svg = null; 108 | if (item.label === "Color") { 109 | svg = ; 110 | } else if (item.label === "Glitch") { 111 | svg = ; 112 | } else if (item.label === "Bounding Box") { 113 | label = "Object"; 114 | svg = ; 115 | } 116 | return ( 117 | 128 | ); 129 | }); 130 | 131 | return ( 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | {filterEls} 140 | 141 | 150 | 151 | 152 | 156 | 157 | 158 | 159 | 160 | A Sample App showcasing! 161 | 162 | 163 | 164 | {statusMessage} 165 | 166 | 167 | ); 168 | }; 169 | 170 | export default Header; 171 | -------------------------------------------------------------------------------- /frontend/components/ImageDisplay.js: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from "styled-components"; 2 | import { ReactComponent as ImageMarkSvg } from "./tilt-imagemark.svg"; 3 | 4 | const rotate = keyframes` 5 | from { 6 | transform: rotate(0deg); 7 | } 8 | 9 | to { 10 | transform: rotate(360deg); 11 | } 12 | `; 13 | 14 | let Container = styled.div` 15 | max-width: 60vw; 16 | max-height: 60vh; 17 | position: relative; 18 | `; 19 | 20 | let Overlay = styled.div` 21 | position: absolute; 22 | align-items: center; 23 | justify-content: center; 24 | top: 0; 25 | bottom: 0; 26 | right: 0; 27 | left: 0; 28 | display: none; 29 | 30 | ${Container}.is-pending & { 31 | display: flex; 32 | } 33 | `; 34 | 35 | let SpinningImageMark = styled(ImageMarkSvg)` 36 | animation: ${rotate} 2s linear infinite; 37 | width: 100px; 38 | height: 100px; 39 | opacity: 0; 40 | transition: opacity 300ms ease; 41 | 42 | ${Container}.is-pending & { 43 | opacity: 1; 44 | } 45 | `; 46 | 47 | let Img = styled.img` 48 | max-width: 60vw; 49 | max-height: 60vh; 50 | transition: opacity 300ms ease; 51 | 52 | ${Container}.is-pending & { 53 | opacity: 0.64; 54 | } 55 | `; 56 | 57 | function ImageDisplay(props) { 58 | let extraClasses = []; 59 | if (props.isPending) { 60 | extraClasses.push("is-pending"); 61 | } 62 | 63 | return ( 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | } 72 | 73 | export default ImageDisplay; 74 | -------------------------------------------------------------------------------- /frontend/components/ImageSelect.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import fetch from "isomorphic-unfetch"; 3 | 4 | let Img = styled.img` 5 | width: 220px; 6 | height: 220px; 7 | margin: 24px; 8 | object-fit: cover; 9 | cursor: pointer; 10 | 11 | &:hover { 12 | width: 236px; 13 | height: 236px; 14 | margin: 16px; 15 | } 16 | `; 17 | 18 | function ImageSelect(props) { 19 | let img = props.img; 20 | let selectImage = props.selectImage; 21 | 22 | let onClick = e => { 23 | selectImage(img); 24 | }; 25 | return ; 26 | } 27 | 28 | export default ImageSelect; 29 | -------------------------------------------------------------------------------- /frontend/components/UploadControl.js: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import color from "./color"; 3 | 4 | let UploadControlRoot = styled.div` 5 | width: 220px; 6 | height: 220px; 7 | margin: 24px; 8 | border: 2px dashed ${color.grayLighter}; 9 | border-radius: 6px; 10 | box-sizing: border-box; 11 | transition: background-color 300ms ease; 12 | 13 | &:hover { 14 | border: 2px solid ${color.grayLighter}; 15 | background-color: ${color.grayLighter}; 16 | } 17 | `; 18 | 19 | let UploadControlLabel = styled.label` 20 | display: flex; 21 | width: 100%; 22 | height: 100%; 23 | justify-content: center; 24 | align-items: center; 25 | font-size: 20px; 26 | font-weight: 700; 27 | line-height: 27px; 28 | color: ${color.gray}; 29 | cursor: pointer; 30 | padding: 24px; 31 | box-sizing: border-box; 32 | text-align: center; 33 | `; 34 | 35 | function UploadControl(props) { 36 | let selectImage = props.selectImage; 37 | 38 | const handleUploadFile = e => { 39 | const blob = e.currentTarget.files[0]; 40 | const url = URL.createObjectURL(blob); 41 | selectImage({ url, blob }); 42 | }; 43 | 44 | return ( 45 | 46 | 52 | 53 | Click to upload a PNG 54 | 55 | 56 | ); 57 | } 58 | 59 | export default UploadControl; 60 | -------------------------------------------------------------------------------- /frontend/components/apply.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/components/color.js: -------------------------------------------------------------------------------- 1 | const color = { 2 | red: "#f6685c", 3 | grayDark: "#002b36", 4 | gray: "#586e75", 5 | grayLight: "#c4c4c4", 6 | grayLighter: "#ccdade", 7 | green: "#20ba31", 8 | yellow: "#fcb41e" 9 | }; 10 | 11 | export default color; 12 | -------------------------------------------------------------------------------- /frontend/components/drop.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/components/mixer.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/components/pixel-tilt-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/components/reset.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/components/scan.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/components/tilt-imagemark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /frontend/components/tilt-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /frontend/k8s.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: frontend 5 | labels: 6 | app: frontend 7 | spec: 8 | selector: 9 | matchLabels: 10 | app: frontend 11 | template: 12 | metadata: 13 | labels: 14 | app: frontend 15 | tier: web 16 | spec: 17 | containers: 18 | - name: frontend 19 | image: frontend 20 | ports: 21 | - containerPort: 3000 22 | --- 23 | apiVersion: v1 24 | kind: Service 25 | metadata: 26 | name: frontend 27 | labels: 28 | app: frontend 29 | spec: 30 | ports: 31 | - port: 80 32 | targetPort: 3000 33 | protocol: TCP 34 | selector: 35 | app: frontend 36 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "dev": "next", 8 | "build": "next build", 9 | "start": "next start" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "axios": "^0.21.1", 16 | "isomorphic-unfetch": "^3.1.0", 17 | "multer": "^1.4.2", 18 | "next": "^11.0.1", 19 | "react": "^17.0.2", 20 | "react-dom": "^17.0.2", 21 | "request": "^2.88.0", 22 | "request-promise-native": "^1.0.8", 23 | "styled-components": "^5.3.0" 24 | }, 25 | "optionalDependencies": { 26 | "fsevents": "^2.3.2" 27 | }, 28 | "devDependencies": { 29 | "babel-plugin-inline-react-svg": "^2.0.1", 30 | "babel-plugin-styled-components": "^1.13.2" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/pages/_app.js: -------------------------------------------------------------------------------- 1 | // global app settings 2 | import "./styles.css"; 3 | 4 | // Adapted from 5 | // https://nextjs.org/docs/advanced-features/custom-app 6 | export default function MyApp({ Component, pageProps }) { 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /frontend/pages/_document.js: -------------------------------------------------------------------------------- 1 | import Document, { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | // Adapted from: 4 | // https://nextjs.org/docs/advanced-features/custom-document 5 | class MyDocument extends Document { 6 | static async getInitialProps(ctx) { 7 | const initialProps = await Document.getInitialProps(ctx) 8 | return { ...initialProps } 9 | } 10 | 11 | render() { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | ) 23 | } 24 | } 25 | 26 | export default MyDocument 27 | -------------------------------------------------------------------------------- /frontend/pages/api/image/[name].js: -------------------------------------------------------------------------------- 1 | import request from "request-promise-native"; 2 | 3 | export default async (req, res) => { 4 | const { 5 | query: { name } 6 | } = req; 7 | 8 | const options = { 9 | method: "POST", 10 | url: "http://storage:8080/read", 11 | json: { 12 | Name: name 13 | } 14 | }; 15 | 16 | var img; 17 | try { 18 | let result = await request(options); 19 | img = Buffer.from(result.Body, "base64"); 20 | } catch (err) { 21 | console.log("Error reading from storage"); 22 | console.log(err); 23 | res.statusCode = 500; 24 | res.end(); 25 | return; 26 | } 27 | 28 | res.writeHead(200, { 29 | "Content-Type": "image/png", 30 | "Content-Length": img.length 31 | }); 32 | res.end(img); 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/pages/api/list.js: -------------------------------------------------------------------------------- 1 | import fetch from "isomorphic-unfetch"; 2 | 3 | export default async (req, res) => { 4 | let list = await fetch("http://storage:8080/list").then(res => res.json()); 5 | res.end(JSON.stringify(list)); 6 | }; 7 | -------------------------------------------------------------------------------- /frontend/pages/api/upload.js: -------------------------------------------------------------------------------- 1 | import multer from "multer"; 2 | import util from "util"; 3 | import fs from "fs"; 4 | import request from "request-promise-native"; 5 | 6 | const uploadStorage = multer({ dest: "uploads/" }); 7 | 8 | export default async (req, res) => { 9 | let upload = util.promisify(uploadStorage.any()); 10 | await upload(req, res); 11 | let filters = req.body.filters; 12 | let filename = req.files[0].filename; 13 | let filePath = "uploads/" + filename; 14 | 15 | let formData = { 16 | myFile: fs.createReadStream(filePath) 17 | }; 18 | let parsedFilters = JSON.parse(filters); 19 | Object.keys(parsedFilters).forEach(item => { 20 | let key = item; 21 | let value = parsedFilters[item]; 22 | 23 | formData[key] = value.toString(); 24 | }); 25 | 26 | const options = { 27 | method: "POST", 28 | url: "http://muxer:8080/upload", 29 | headers: { 30 | "Content-Type": "multipart/form-data" 31 | }, 32 | formData: formData, 33 | json: true 34 | }; 35 | 36 | const proxyResult = await request(options); 37 | 38 | res.end(JSON.stringify(proxyResult)); 39 | }; 40 | 41 | export const config = { 42 | api: { 43 | bodyParser: false 44 | } 45 | }; 46 | -------------------------------------------------------------------------------- /frontend/pages/index.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Header from "../components/Header"; 3 | import styled from "styled-components"; 4 | import fetch from "isomorphic-unfetch"; 5 | import axios from "axios"; 6 | import UploadControl from "../components/UploadControl"; 7 | import ImageSelect from "../components/ImageSelect"; 8 | import ImageDisplay from "../components/ImageDisplay"; 9 | import Button from "../components/Button"; 10 | 11 | let Root = styled.div` 12 | width: 100%; 13 | min-height: 100vh; 14 | display: flex; 15 | flex-direction: column; 16 | `; 17 | 18 | let MainPane = styled.main` 19 | display: flex; 20 | justify-content: center; 21 | align-items: center; 22 | flex-grow: 1; 23 | `; 24 | 25 | let ImageGrid = styled.div` 26 | display: grid; 27 | grid-template-columns: 50% 50%; 28 | `; 29 | 30 | let HistoryButtonContainer = styled.div` 31 | position: fixed; 32 | right: 0; 33 | bottom: 0; 34 | margin: 16px; 35 | `; 36 | 37 | const Index = props => { 38 | const filters = props.filters; 39 | const defaultCheckedItems = props.defaultCheckedItems; 40 | 41 | const [historyImages, setHistoryImages] = useState([]); 42 | const [showHistory, setShowHistory] = useState(false); 43 | const [recentImages, setRecentImages] = useState(props.defaultImages); 44 | const [checkedItems, setCheckedItems] = useState(defaultCheckedItems); 45 | const [fileSelection, setFileSelection] = useState(); 46 | const [resultingImage, setResultingImage] = useState(""); 47 | const [applyState, setApplyState] = useState({ 48 | inProgress: false, 49 | error: null 50 | }); 51 | 52 | const toggleHistory = () => { 53 | if (showHistory) { 54 | setShowHistory(false); 55 | return; 56 | } 57 | 58 | fetch("/api/list") 59 | .then(res => res.json()) 60 | .then(json => { 61 | let names = json.Names || []; 62 | if (names.length == 0) { 63 | setApplyState({ error: "No images in storage" }); 64 | return; 65 | } 66 | 67 | reset(); 68 | setHistoryImages( 69 | names.map(name => { 70 | return { url: `/api/image/${name}` }; 71 | }) 72 | ); 73 | setShowHistory(true); 74 | }) 75 | .catch(err => { 76 | setApplyState({ error: "Error fetching history: " + err }); 77 | }); 78 | }; 79 | 80 | const selectImage = img => { 81 | let url = img.url; 82 | let blob = img.blob; 83 | let blobPromise = blob 84 | ? Promise.resolve(blob) 85 | : fetch(url).then(res => res.blob()); 86 | 87 | blobPromise 88 | .then(blob => { 89 | img.blob = blob; 90 | 91 | // Append the image to the front of the recency list, 92 | // and remove it if it already exists in the list. 93 | let newRecentImages = [img].concat( 94 | recentImages.filter(existing => { 95 | return existing.url != url; 96 | }) 97 | ); 98 | setRecentImages(newRecentImages); 99 | setFileSelection(img); 100 | setShowHistory(false); 101 | }) 102 | .catch(err => { 103 | setApplyState({ error: "Error fetching image: " + err }); 104 | setShowHistory(false); 105 | }); 106 | }; 107 | 108 | const statusMessage = () => { 109 | if (applyState.inProgress) { 110 | return "Applying filter…"; 111 | } 112 | 113 | if (applyState.error) { 114 | return applyState.error; 115 | } 116 | 117 | if (resultingImage || fileSelection) { 118 | return "Select one or more filters and apply"; 119 | } 120 | 121 | return "Upload or Select an Image. Apply filters once selected"; 122 | }; 123 | 124 | const clearAndSetCheckedItems = checkedItems => { 125 | setResultingImage(""); 126 | setCheckedItems(checkedItems); 127 | setApplyState({ inProgress: false, error: null }); 128 | }; 129 | 130 | const reset = () => { 131 | setShowHistory(false); 132 | setResultingImage(""); 133 | setFileSelection(null); 134 | setCheckedItems(defaultCheckedItems); 135 | setApplyState({ inProgress: false, error: null }); 136 | }; 137 | 138 | const apply = async () => { 139 | if (!fileSelection) { 140 | throw new Error("internal error: no file to apply filters on"); 141 | } 142 | 143 | const data = new FormData(); 144 | const file = fileSelection.blob; 145 | data.append("file", file); 146 | const filters = Object.keys(checkedItems) 147 | .filter(key => checkedItems[key]) 148 | .reduce((res, key) => ((res[key] = checkedItems[key]), res), {}); 149 | data.append("filters", JSON.stringify(filters)); 150 | setApplyState({ inProgress: true }); 151 | 152 | axios 153 | .post("/api/upload", data, {}) 154 | .then(response => { 155 | setResultingImage(response.data.name); 156 | setApplyState({ inProgress: false }); 157 | }) 158 | .catch(err => { 159 | setApplyState({ error: "Error applying filter: " + err }); 160 | }); 161 | }; 162 | 163 | const renderContent = () => { 164 | if (resultingImage) { 165 | return ; 166 | } 167 | 168 | if (fileSelection) { 169 | return ( 170 | 174 | ); 175 | } 176 | 177 | let cells = []; 178 | cells.push( 179 | 180 | ); 181 | cells = cells.concat( 182 | recentImages.slice(0, 3).map((img, i) => { 183 | return ( 184 | 185 | ); 186 | }) 187 | ); 188 | 189 | if (showHistory) { 190 | cells = historyImages.slice(0, 10).map((img, i) => { 191 | return ( 192 | 193 | ); 194 | }); 195 | } 196 | 197 | return {cells}; 198 | }; 199 | 200 | return ( 201 | 202 |
211 | {renderContent()} 212 | 213 | 221 | 222 | 223 | ); 224 | }; 225 | 226 | Index.getInitialProps = async function() { 227 | const filtersData = await fetch("http://muxer:8080/filters").then(res => 228 | res.json() 229 | ); 230 | 231 | const staticImageUrls = ["/baby-bear.png", "/plane.png", "/duck.png"]; 232 | let defaultImages = staticImageUrls.map(url => { 233 | return { url: url }; 234 | }); 235 | 236 | const defaultCheckedItems = {}; 237 | filtersData.forEach( 238 | c => (defaultCheckedItems["filter_" + c.label.toLowerCase()] = false) 239 | ); 240 | 241 | return { 242 | filters: filtersData, 243 | defaultCheckedItems: defaultCheckedItems, 244 | defaultImages: defaultImages 245 | }; 246 | }; 247 | 248 | export default Index; 249 | -------------------------------------------------------------------------------- /frontend/pages/styles.css: -------------------------------------------------------------------------------- 1 | /** global styles */ 2 | 3 | body { 4 | margin: 0; 5 | font-size: 16px; 6 | font-family: 'Noto Sans', sans-serif; 7 | /* filter: invert(1); */ 8 | } 9 | -------------------------------------------------------------------------------- /frontend/public/baby-bear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/frontend/public/baby-bear.png -------------------------------------------------------------------------------- /frontend/public/duck.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/frontend/public/duck.png -------------------------------------------------------------------------------- /frontend/public/plane.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/frontend/public/plane.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tilt-dev/pixeltilt 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/fogleman/gg v1.3.0 7 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 8 | github.com/peterbourgon/diskv/v3 v3.0.0 9 | github.com/pkg/errors v0.9.1 10 | github.com/sug0/go-glitch v0.0.0-20210430141212-001bcbbffd0a 11 | golang.org/x/image v0.0.0-20200119044424-58c23975cae1 // indirect 12 | ) 13 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cheggaaa/pb v1.0.29/go.mod h1:W40334L7FMC5JKWldsTWbdGjLo0RxUKK73K+TuPxX30= 2 | github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= 3 | github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= 4 | github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= 5 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= 6 | github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= 7 | github.com/google/btree v1.0.0 h1:0udJVsspx3VBr5FwtLhQQtuAsVc79tTq0ocGIPAU6qo= 8 | github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= 9 | github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= 10 | github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= 11 | github.com/mattn/go-isatty v0.0.11/go.mod h1:PhnuNfih5lzO57/f3n+odYbM4JtupLOxQOAqxQCu2WE= 12 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 13 | github.com/peterbourgon/diskv/v3 v3.0.0 h1:iPZxiSeh/S8JIAl2rIhCSYlUIgZWjd9mYswxZfeUI3s= 14 | github.com/peterbourgon/diskv/v3 v3.0.0/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o= 15 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 16 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 17 | github.com/sug0/go-exit v0.0.0-20190205235601-47997d719c0d/go.mod h1:0yI0wMw4CyNlMjIpDpIrImEHeit6waWoaJlGQRMCymA= 18 | github.com/sug0/go-glitch v0.0.0-20210430141212-001bcbbffd0a h1:VASyCcRjH8AesA39NkpQ5/A3Mp/jhTMgxWptoszmUrY= 19 | github.com/sug0/go-glitch v0.0.0-20210430141212-001bcbbffd0a/go.mod h1:Su9SgmOCmAqkWQNW/jvNDY4NJn5vLKOhTpoq/7QCcc8= 20 | golang.org/x/image v0.0.0-20200119044424-58c23975cae1 h1:5h3ngYt7+vXCDZCup/HkCQgW5XwmSvR/nA2JmJ0RErg= 21 | golang.org/x/image v0.0.0-20200119044424-58c23975cae1/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= 22 | golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 23 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 24 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 25 | -------------------------------------------------------------------------------- /muxer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM pixeltilt-base 2 | 3 | COPY ../render/api render/api/ 4 | COPY ../storage/api storage/api/ 5 | COPY ../storage/client storage/client/ 6 | COPY muxer/ muxer/ 7 | 8 | RUN --mount=type=cache,target=/cache/gomod \ 9 | --mount=type=cache,target=/cache/gobuild,sharing=locked \ 10 | find . -name "*.go" && \ 11 | go mod vendor && \ 12 | go build -mod=vendor -o /usr/local/bin/muxer ./muxer 13 | 14 | CMD ["/usr/local/bin/muxer"] 15 | -------------------------------------------------------------------------------- /muxer/k8s.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: muxer 6 | labels: 7 | app: muxer 8 | spec: 9 | selector: 10 | matchLabels: 11 | app: muxer 12 | template: 13 | metadata: 14 | labels: 15 | app: muxer 16 | spec: 17 | containers: 18 | - name: muxer 19 | image: muxer 20 | ports: 21 | - containerPort: 8080 22 | --- 23 | apiVersion: v1 24 | kind: Service 25 | metadata: 26 | name: muxer 27 | labels: 28 | app: muxer 29 | spec: 30 | ports: 31 | - port: 8080 32 | targetPort: 8080 33 | protocol: TCP 34 | selector: 35 | app: muxer 36 | -------------------------------------------------------------------------------- /muxer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "net/url" 11 | "os" 12 | "sort" 13 | "strings" 14 | 15 | "github.com/pkg/errors" 16 | 17 | "github.com/tilt-dev/pixeltilt/render/api" 18 | 19 | "github.com/tilt-dev/pixeltilt/storage/client" 20 | ) 21 | 22 | type filter struct { 23 | Label string `json:"label"` 24 | URL string `json:"url"` 25 | NeedsOriginal bool `json:"needsoriginal"` 26 | } 27 | 28 | // Order matters!! 29 | var enabledFilters = []filter{ 30 | filter{"Color", "http://color:8080", false}, 31 | filter{"Glitch", "http://glitch:8080", false}, 32 | filter{"Bounding Box", "http://bounding-box:8080", true}, 33 | } 34 | 35 | var storage client.Storage 36 | 37 | func main() { 38 | fmt.Println("Muxer running!") 39 | port := "8080" 40 | if len(os.Args) > 1 { 41 | port = os.Args[1] 42 | } 43 | 44 | var err error 45 | storage, err = client.NewStorageClient("http://storage:8080") 46 | if err != nil { 47 | log.Fatalf("initializing storage client: %v", err) 48 | } 49 | 50 | http.HandleFunc("/", index) 51 | http.HandleFunc("/filters", filters) 52 | http.HandleFunc("/images", images) 53 | http.HandleFunc("/upload", upload) 54 | http.HandleFunc("/access/", access) 55 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil)) 56 | } 57 | 58 | func index(w http.ResponseWriter, r *http.Request) { 59 | html := ` 60 | 61 | 62 | 63 | Document 64 | 65 | 66 |
` 71 | 72 | imageKeys, err := storage.List() 73 | if err != nil { 74 | fmt.Println(err) 75 | return 76 | } 77 | 78 | //
79 | for i := 0; i < len(enabledFilters); i++ { 80 | lowerName := strings.ToLower(enabledFilters[i].Label) 81 | html += `
` 82 | } 83 | 84 | html += ` 85 | 86 | 87 |
88 |
` 89 | 90 | // show current db entries 91 | for _, key := range imageKeys { 92 | html += "" + key + "

" 93 | } 94 | 95 | html += ` 96 | 97 | ` 98 | 99 | _, err = w.Write([]byte(html)) 100 | if err != nil { 101 | fmt.Println(err) 102 | return 103 | } 104 | } 105 | 106 | func access(w http.ResponseWriter, r *http.Request) { 107 | key := strings.Split(r.URL.Path, "/")[2] 108 | image, err := storage.Read(key) 109 | if err != nil { 110 | fmt.Println(err) 111 | return 112 | } 113 | w.Header().Set("Content-Type", "image/png") 114 | _, err = w.Write(image) 115 | if err != nil { 116 | fmt.Println(err) 117 | return 118 | } 119 | } 120 | 121 | func filters(w http.ResponseWriter, r *http.Request) { 122 | j, err := json.MarshalIndent(enabledFilters, "", " ") 123 | if err != nil { 124 | handleHTTPErr(w, err) 125 | return 126 | } 127 | 128 | w.Header().Set("Content-Type", "application/json") 129 | 130 | _, err = w.Write(j) 131 | if err != nil { 132 | fmt.Println(err) 133 | } 134 | } 135 | 136 | func images(w http.ResponseWriter, r *http.Request) { 137 | imageKeys, err := storage.List() 138 | if err != nil { 139 | handleHTTPErr(w, err) 140 | return 141 | } 142 | 143 | j, err := json.MarshalIndent(imageKeys, "", " ") 144 | if err != nil { 145 | handleHTTPErr(w, err) 146 | return 147 | } 148 | 149 | w.Header().Set("Content-Type", "application/json") 150 | _, err = w.Write(j) 151 | if err != nil { 152 | fmt.Println(err) 153 | } 154 | } 155 | 156 | type uploadResponse struct { 157 | Name string `json:"name"` 158 | } 159 | 160 | // TODO(dmiller): this should return the image URL instead of the image itself 161 | func upload(w http.ResponseWriter, r *http.Request) { 162 | originalImageBytes, filename, err := fileFromRequest(r) 163 | if err != nil { 164 | handleHTTPErr(w, err) 165 | return 166 | } 167 | 168 | err = r.ParseForm() 169 | if err != nil { 170 | handleHTTPErr(w, httpStatusError{http.StatusBadRequest, err}) 171 | return 172 | } 173 | 174 | filters := filtersFromValues(r.PostForm) 175 | sort.Ints(filters) 176 | 177 | modifiedImage, err := applyFilters(originalImageBytes, filters) 178 | if err != nil { 179 | handleHTTPErr(w, err) 180 | return 181 | } 182 | 183 | // save to db 184 | encoded := base64.StdEncoding.EncodeToString(modifiedImage) 185 | name, err := storage.Write(filename, []byte(encoded)) 186 | if err != nil { 187 | handleHTTPErr(w, err) 188 | return 189 | } 190 | 191 | resp := uploadResponse{ 192 | Name: name, 193 | } 194 | 195 | w.Header().Set("Content-Type", "application/json") 196 | err = json.NewEncoder(w).Encode(resp) 197 | if err != nil { 198 | log.Printf("Error JSON encoding response: %v", err) 199 | } 200 | } 201 | 202 | func filtersFromValues(values url.Values) []int { 203 | var ret []int 204 | message := "Filters to apply: " 205 | for paramName := range values { 206 | fmt.Printf("Checking if %s is enabling a filter...\n", paramName) 207 | if !strings.HasPrefix(paramName, "filter_") { 208 | continue 209 | } 210 | name := strings.TrimPrefix(paramName, "filter_") 211 | for i := 0; i < len(enabledFilters); i++ { 212 | if strings.ToLower(enabledFilters[i].Label) == name { 213 | message += enabledFilters[i].Label + " " 214 | ret = append(ret, i) 215 | } 216 | } 217 | } 218 | fmt.Println(message) 219 | return ret 220 | } 221 | 222 | type httpStatusError struct { 223 | code int 224 | err error 225 | } 226 | 227 | func (e httpStatusError) Error() string { 228 | return e.err.Error() 229 | } 230 | 231 | func fileFromRequest(r *http.Request) (image []byte, filename string, err error) { 232 | // receive file 233 | file, header, err := r.FormFile("myFile") 234 | if err != nil { 235 | return nil, "", errors.Wrap(err, "getting file from request") 236 | } 237 | defer file.Close() 238 | fmt.Printf("Uploaded File: %+v\tFile Size: %+v\tMIME: %+v\n", header.Filename, header.Size, header.Header) 239 | 240 | fileBytes, err := ioutil.ReadAll(file) 241 | if err != nil { 242 | return nil, "", errors.Wrap(err, "reading uploaded file from request") 243 | } 244 | 245 | imageType := http.DetectContentType(fileBytes) 246 | if imageType != "image/png" { 247 | // https://www.bennadel.com/blog/2434-http-status-codes-for-invalid-data-400-vs-422.htm 248 | return nil, "", httpStatusError{http.StatusUnprocessableEntity, fmt.Errorf("invalid image type: expected \"image/png\", got: %s", imageType)} 249 | } 250 | 251 | return fileBytes, header.Filename, nil 252 | } 253 | 254 | func applyFilters(imageBytes []byte, filterIndexes []int) ([]byte, error) { 255 | currentImageBytes := append([]byte{}, imageBytes...) 256 | 257 | for _, f := range filterIndexes { 258 | var err error 259 | fmt.Println("Applying Filter:", enabledFilters[f].Label) 260 | currentImageBytes, err = applyFilter(enabledFilters[f], currentImageBytes, imageBytes) 261 | if err != nil { 262 | // return nil, fmt.Errorf("Error applying %s: %v", enabledFilters[f].Label, err) 263 | } 264 | } 265 | return currentImageBytes, nil 266 | } 267 | 268 | func applyFilter(filter filter, imageBytes []byte, originalImageBytes []byte) ([]byte, error) { 269 | rr := api.RenderRequest{Image: imageBytes} 270 | if filter.NeedsOriginal { 271 | rr.OriginalImage = originalImageBytes 272 | } 273 | 274 | resp, err := api.PostRequest(rr, filter.URL) 275 | if err != nil { 276 | return nil, err 277 | } 278 | 279 | return resp.Image, nil 280 | } 281 | 282 | func handleHTTPErr(w http.ResponseWriter, err error) { 283 | fmt.Println(err.Error()) 284 | status := http.StatusInternalServerError 285 | if se, ok := err.(httpStatusError); ok { 286 | status = se.code 287 | } 288 | http.Error(w, err.Error(), status) 289 | } 290 | -------------------------------------------------------------------------------- /object-detector/k8s.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: max-object-detector 5 | labels: 6 | app: max-object-detector 7 | spec: 8 | selector: 9 | matchLabels: 10 | app: max-object-detector 11 | template: 12 | metadata: 13 | labels: 14 | app: max-object-detector 15 | tier: backend 16 | spec: 17 | containers: 18 | - name: max-object-detector 19 | image: codait/max-object-detector 20 | ports: 21 | - containerPort: 5000 22 | resources: 23 | requests: 24 | cpu: "10m" 25 | --- 26 | apiVersion: v1 27 | kind: Service 28 | metadata: 29 | name: max-object-detector 30 | labels: 31 | app: max-object-detector 32 | spec: 33 | ports: 34 | - port: 5000 35 | targetPort: 5000 36 | protocol: TCP 37 | selector: 38 | app: max-object-detector 39 | -------------------------------------------------------------------------------- /pixeltilt.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tilt-dev/pixeltilt/d5525ff63d7cda7d66194d7fea7884fbeb82c4bc/pixeltilt.gif -------------------------------------------------------------------------------- /render/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | type RenderRequest struct { 15 | Image []byte 16 | // OriginalImage is optional 17 | OriginalImage []byte 18 | } 19 | 20 | type RenderReply struct { 21 | Image []byte 22 | } 23 | 24 | type Renderer func(req RenderRequest) (RenderReply, error) 25 | 26 | func HttpRenderHandler(renderer Renderer) func(w http.ResponseWriter, req *http.Request) { 27 | return func(w http.ResponseWriter, req *http.Request) { 28 | rr, err := ReadRequest(req) 29 | if err != nil { 30 | http.Error(w, err.Error(), http.StatusBadRequest) 31 | } 32 | 33 | resp, err := renderer(rr) 34 | if err != nil { 35 | msg := fmt.Sprintf("error transforming image: %v", err) 36 | log.Println(msg) 37 | http.Error(w, msg, http.StatusInternalServerError) 38 | } 39 | 40 | err = WriteResponse(w, resp) 41 | if err != nil { 42 | log.Printf("error writing response: %v", err) 43 | } 44 | } 45 | } 46 | 47 | func ReadRequest(req *http.Request) (RenderRequest, error) { 48 | d := json.NewDecoder(req.Body) 49 | d.DisallowUnknownFields() 50 | var rr RenderRequest 51 | err := d.Decode(&rr) 52 | return rr, errors.Wrap(err, "decoding request body") 53 | } 54 | 55 | func WriteResponse(w http.ResponseWriter, resp RenderReply) error { 56 | return errors.Wrap(json.NewEncoder(w).Encode(resp), "encoding response body") 57 | } 58 | 59 | func PostRequest(req RenderRequest, url string) (RenderReply, error) { 60 | var buf bytes.Buffer 61 | err := json.NewEncoder(&buf).Encode(req) 62 | if err != nil { 63 | return RenderReply{}, errors.Wrap(err, "encoding request body") 64 | } 65 | 66 | resp, err := http.Post(url, "image/png", &buf) 67 | if err != nil { 68 | return RenderReply{}, errors.Wrap(err, "making post request") 69 | } 70 | 71 | if resp.StatusCode != http.StatusOK { 72 | body, err := ioutil.ReadAll(resp.Body) 73 | if err != nil { 74 | return RenderReply{}, errors.Wrapf(err, "reading response body w/ status %s", resp.Status) 75 | } 76 | return RenderReply{}, fmt.Errorf("post request returned status %s: %s", resp.Status, string(body)) 77 | } 78 | 79 | var reply RenderReply 80 | d := json.NewDecoder(resp.Body) 81 | d.DisallowUnknownFields() 82 | err = d.Decode(&reply) 83 | return reply, errors.Wrap(err, "decoding reply") 84 | } 85 | -------------------------------------------------------------------------------- /storage/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM pixeltilt-base 2 | 3 | COPY storage/ storage/ 4 | 5 | RUN --mount=type=cache,target=/cache/gomod \ 6 | --mount=type=cache,target=/cache/gobuild,sharing=locked \ 7 | go mod vendor && \ 8 | go build -mod=vendor -o /usr/local/bin/storage ./storage 9 | 10 | CMD ["/usr/local/bin/storage"] 11 | -------------------------------------------------------------------------------- /storage/api/api.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type WriteRequest struct { 4 | Name string 5 | Body []byte 6 | } 7 | 8 | type WriteResponse struct { 9 | Name string 10 | } 11 | 12 | type ReadRequest struct { 13 | Name string 14 | } 15 | 16 | type ReadResponse struct { 17 | Body []byte 18 | } 19 | 20 | type ListResponse struct { 21 | Names []string 22 | } 23 | -------------------------------------------------------------------------------- /storage/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/tilt-dev/pixeltilt/storage/api" 11 | 12 | "github.com/pkg/errors" 13 | ) 14 | 15 | type Storage struct { 16 | url *url.URL 17 | } 18 | 19 | func NewStorageClient(rawurl string) (Storage, error) { 20 | u, err := url.Parse(rawurl) 21 | if err != nil { 22 | return Storage{}, errors.Wrapf(err, "parsing url %q", rawurl) 23 | } 24 | return Storage{url: u}, nil 25 | } 26 | 27 | func (sc *Storage) Write(name string, b []byte) (string, error) { 28 | wr := api.WriteRequest{Name: name, Body: b} 29 | buf := &bytes.Buffer{} 30 | err := json.NewEncoder(buf).Encode(wr) 31 | if err != nil { 32 | return "", errors.Wrap(err, "encoding write request") 33 | } 34 | u, _ := url.Parse(sc.url.String()) 35 | u.Path = "/write" 36 | resp, err := http.Post(u.String(), "application/json", buf) 37 | if err != nil { 38 | return "", errors.Wrap(err, "http posting") 39 | } 40 | 41 | if resp.StatusCode != http.StatusOK { 42 | respBody, err := ioutil.ReadAll(resp.Body) 43 | if err != nil { 44 | return "", errors.Wrap(err, "reading response body on non-200 response") 45 | } 46 | return "", errors.Errorf("http status %s, body %q", resp.Status, string(respBody)) 47 | } 48 | 49 | var wresp api.WriteResponse 50 | err = json.NewDecoder(resp.Body).Decode(&wresp) 51 | if err != nil { 52 | errors.Wrap(err, "reading response body on 200 response") 53 | } 54 | 55 | return wresp.Name, nil 56 | } 57 | 58 | func (sc *Storage) Read(name string) ([]byte, error) { 59 | rreq := api.ReadRequest{Name: name} 60 | buf := &bytes.Buffer{} 61 | err := json.NewEncoder(buf).Encode(rreq) 62 | if err != nil { 63 | return nil, errors.Wrap(err, "encoding read request") 64 | } 65 | 66 | u, _ := url.Parse(sc.url.String()) 67 | u.Path = "/read" 68 | resp, err := http.Post(u.String(), "application/json", buf) 69 | if err != nil { 70 | return nil, errors.Wrap(err, "http posting") 71 | } 72 | 73 | if resp.StatusCode != http.StatusOK { 74 | respBody, err := ioutil.ReadAll(resp.Body) 75 | if err != nil { 76 | return nil, errors.Wrap(err, "reading response body on non-200 response") 77 | } 78 | return nil, errors.Errorf("http status %s, body %q", resp.Status, string(respBody)) 79 | } 80 | 81 | var rresp api.ReadResponse 82 | err = json.NewDecoder(resp.Body).Decode(&rresp) 83 | if err != nil { 84 | return nil, errors.Wrap(err, "decoding http response") 85 | } 86 | 87 | return rresp.Body, nil 88 | } 89 | 90 | func (sc *Storage) List() ([]string, error) { 91 | u, _ := url.Parse(sc.url.String()) 92 | u.Path = "/list" 93 | resp, err := http.Get(u.String()) 94 | if err != nil { 95 | return nil, errors.Wrap(err, "http getting") 96 | } 97 | 98 | if resp.StatusCode != http.StatusOK { 99 | respBody, err := ioutil.ReadAll(resp.Body) 100 | if err != nil { 101 | return nil, errors.Wrap(err, "reading response body on non-200 response") 102 | } 103 | return nil, errors.Errorf("http status %s, body %q", resp.Status, string(respBody)) 104 | } 105 | 106 | var lr api.ListResponse 107 | dec := json.NewDecoder(resp.Body) 108 | dec.DisallowUnknownFields() 109 | err = dec.Decode(&lr) 110 | if err != nil { 111 | return nil, errors.Wrap(err, "decoding http response") 112 | } 113 | 114 | // Don't return null because null is the root of all evil 115 | if lr.Names == nil { 116 | return []string{}, nil 117 | } 118 | 119 | return lr.Names, nil 120 | } 121 | -------------------------------------------------------------------------------- /storage/k8s.yaml: -------------------------------------------------------------------------------- 1 | 2 | apiVersion: apps/v1 3 | kind: Deployment 4 | metadata: 5 | name: storage 6 | labels: 7 | app: storage 8 | spec: 9 | selector: 10 | matchLabels: 11 | app: storage 12 | template: 13 | metadata: 14 | labels: 15 | app: storage 16 | spec: 17 | containers: 18 | - name: storage 19 | image: storage 20 | ports: 21 | - containerPort: 8080 22 | --- 23 | apiVersion: v1 24 | kind: Service 25 | metadata: 26 | name: storage 27 | labels: 28 | app: storage 29 | spec: 30 | ports: 31 | - port: 8080 32 | targetPort: 8080 33 | protocol: TCP 34 | selector: 35 | app: storage 36 | -------------------------------------------------------------------------------- /storage/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/tilt-dev/pixeltilt/storage/api" 15 | 16 | "github.com/peterbourgon/diskv/v3" 17 | ) 18 | 19 | var d = diskv.New(diskv.Options{ 20 | BasePath: "diskv", 21 | Transform: func(s string) []string { return []string{} }, 22 | CacheSizeMax: 1024 * 1024, // 1MB 23 | }) 24 | 25 | func main() { 26 | fmt.Println("Storage running!") 27 | port := "8080" 28 | if len(os.Args) > 1 { 29 | port = os.Args[1] 30 | } 31 | http.HandleFunc("/write", write) 32 | http.HandleFunc("/read", read) 33 | http.HandleFunc("/list", list) 34 | http.HandleFunc("/flush", flush) 35 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), nil)) 36 | } 37 | 38 | func read(w http.ResponseWriter, r *http.Request) { 39 | var rreq api.ReadRequest 40 | decoder := json.NewDecoder(r.Body) 41 | decoder.DisallowUnknownFields() 42 | err := decoder.Decode(&rreq) 43 | if err != nil { 44 | http.Error(w, fmt.Sprintf("error decoding request: %v", err), http.StatusBadRequest) 45 | return 46 | } 47 | 48 | if rreq.Name == "" { 49 | http.Error(w, "no name specified", http.StatusBadRequest) 50 | return 51 | } 52 | 53 | image, err := d.Read(rreq.Name) 54 | if err != nil { 55 | // TODO: depending on err, this might be a 404 or something 56 | http.Error(w, fmt.Sprintf("error reading image from storage: %v", err), http.StatusInternalServerError) 57 | return 58 | } 59 | 60 | decoded, err := base64.StdEncoding.DecodeString(string(image)) 61 | if err != nil { 62 | http.Error(w, fmt.Sprintf("error decoding image from storage: %v", err), http.StatusInternalServerError) 63 | return 64 | } 65 | 66 | w.Header().Set("Content-Type", "image/png") 67 | 68 | rresp := api.ReadResponse{Body: decoded} 69 | err = json.NewEncoder(w).Encode(rresp) 70 | if err != nil { 71 | http.Error(w, fmt.Sprintf("error encoding response: %v", err), http.StatusInternalServerError) 72 | return 73 | } 74 | } 75 | 76 | func write(w http.ResponseWriter, r *http.Request) { 77 | var wr api.WriteRequest 78 | decoder := json.NewDecoder(r.Body) 79 | decoder.DisallowUnknownFields() 80 | err := decoder.Decode(&wr) 81 | if err != nil { 82 | http.Error(w, fmt.Sprintf("error decoding request body: %v", err), http.StatusBadRequest) 83 | return 84 | } 85 | 86 | if wr.Name == "" { 87 | http.Error(w, "no Name specified", http.StatusBadRequest) 88 | return 89 | } 90 | 91 | if len(wr.Body) == 0 { 92 | http.Error(w, "no file specified", http.StatusBadRequest) 93 | return 94 | } 95 | 96 | filenameWithoutExtension := strings.TrimSuffix(wr.Name, filepath.Ext(wr.Name)) 97 | name := fmt.Sprintf("%s-%s.png", filenameWithoutExtension, time.Now().Format("2006-01-02-15-04-05")) 98 | 99 | err = d.Write(name, wr.Body) 100 | if err != nil { 101 | http.Error(w, err.Error(), http.StatusInternalServerError) 102 | return 103 | } 104 | 105 | response := api.WriteResponse{Name: name} 106 | err = json.NewEncoder(w).Encode(&response) 107 | } 108 | 109 | func list(w http.ResponseWriter, r *http.Request) { 110 | var lr api.ListResponse 111 | for key := range d.Keys(nil) { 112 | lr.Names = append(lr.Names, key) 113 | } 114 | 115 | err := json.NewEncoder(w).Encode(&lr) 116 | if err != nil { 117 | http.Error(w, fmt.Sprintf("error encoding response: %v", err), http.StatusInternalServerError) 118 | return 119 | } 120 | } 121 | 122 | func flush(w http.ResponseWriter, r *http.Request) { 123 | dir, err := os.ReadDir("/app/diskv") 124 | if err == nil { 125 | for _, d := range dir { 126 | _ = os.RemoveAll(filepath.Join("diskv", d.Name())) 127 | } 128 | } else if !os.IsNotExist(err) { 129 | http.Error(w, fmt.Sprintf("error reading diskv directory: %v", err), http.StatusInternalServerError) 130 | return 131 | } 132 | 133 | _, _ = w.Write([]byte("Database entries deleted!\n")) 134 | } 135 | --------------------------------------------------------------------------------