motion-canvas icon indicating copy to clipboard operation
motion-canvas copied to clipboard

Rendering fails when using remote pictures

Open WaldemarLehner opened this issue 2 years ago • 5 comments

Describe the bug If a scene has images from remote source, trying to render will result in an error:

Failed to execute 'toDataURL' on 'HTMLCanvasElement': Tainted canvases may not be exported. image

To Reproduce Have a scene with a remote Image, then try to render.

import { makeScene2D } from "@motion-canvas/2d";
import { Image, Layout } from "@motion-canvas/2d/lib/components";
import { waitFor } from "@motion-canvas/core/lib/flow";

export default makeScene2D(function* (scene) {
    scene.add(<Layout>
        <Image scale={.2} src={"https://images.unsplash.com/photo-1515229144611-617d3ce8e108"}/> {/* any remote picture works */}
    </Layout>)
    yield* waitFor(1)
});

Additional context Error occurs both on Firefox and Chromium.

WaldemarLehner avatar Feb 14 '23 03:02 WaldemarLehner

Kinda similar to #234 Not sure if we can fix this since it seems like a CORS issue on the image server side

aarthificial avatar Feb 14 '23 08:02 aarthificial

What might work is defining a proxy in vite.config.ts that does all requests on behalf of the Browser.

I would add a new helper utility that wraps a src string, like https://via.placeholder.com/300.png/09f/fff into a base64, escaped string that gets passed to a proxy endpoint on vite, say /proxy.

const originalSrc: string;
return `/proxy/${encodeURIComponent(originalSrc)}`

I will have to look into how proxying works with vite. A quick glance at the docs tells me I need to specify a server, which would not work in this use case as we would need to define all endpoints.

WaldemarLehner avatar Feb 14 '23 14:02 WaldemarLehner

image

Proof of Concept works

Click to expand / Outdated: I basically implemented a Plugin for Vite which adds some a Middleware to Vite's Server that intercepts any calls starting with `/proxy/`.
const proxyPlugin = (): Plugin => {
  
  return {
    name: "proxy-server",
    configureServer(server) {

      server.middlewares.use((req, res, next) => {
        if (!req.url || !req.url.startsWith("/proxy/")) {
          return next()
        }

        if(req.method !== "GET") {
          const msg = "Only GET requests are allowed"
          res.writeHead(400, msg)
          res.write(msg)
          res.end()
          return;
        }
        
        // Extract destination, e.g 
        function extractDestinationUrl(url: string) {
          try {
            const withoutPrefix = url.replace("/proxy/", "")
            const asUrl = new URL(decodeURIComponent(withoutPrefix))
            console.log(`"${asUrl.protocol}"`)
            if(asUrl.protocol !== "http:" && asUrl.protocol !== "https:") {
              throw new Error("Only supported protocols are http and https") // TODO: Is this enough to prevent access to file:// ?
            }

            return [asUrl, undefined] as const
          }
          catch(err) {
            return [undefined, err+""] as const
          }
        }

        const [sourceUrl, error] = extractDestinationUrl(req.url)

        if (error || !sourceUrl) {
          res.writeHead(400, error)
          res.write(error)
          res.end()
          return
        }

        
        // Call URL on behalf of callee
        const proxy_req = http.request( {
          hostname: sourceUrl.hostname,
          path: sourceUrl.pathname,
          method: "get"
        }, (proxy_res) => { 
          let buffer: any[] = []
          console.log("ping")
          proxy_res.on("data", (chunk) => {
            buffer.push(chunk )
          })

          proxy_res.on("error", () => {
            res.statusCode = 400
            res.statusMessage = "Proxying failed"
            res.end()
          })

          proxy_res.on("close", () => {
            const body = Buffer.concat(buffer)
            // only proxy headers we actually need
            const allowedHeaders = [
              "content-type", "content-length", "cache-control"
            ]
            for(const header of allowedHeaders) {
              const headerValue = proxy_res.headers[header]
              if(!headerValue) {
                continue // Header does not exist. Ignore
              }
              res.setHeader(header, headerValue)
            }
            // Additionally set the X-Proxied-Url Header for debugging
            res.setHeader("X-Proxied-Url", sourceUrl.toString())

            
            res.end(body)
            console.log("done")
          })

        }).end()

      })
    }
  }
}

WaldemarLehner avatar Feb 14 '23 16:02 WaldemarLehner

Yesterday I took the time to create a Plugin that provides a proxy server, and a utility function to wrap remote requests, so that they get sent to the proxy instead. It's basically what I shown in the Proof of Concept, with more checks, configuration and using Axios instead of raw http

https://github.com/WaldemarLehner/motion-canvas-cors-proxy

@Ross-Esmond mentioned it might be a good idea to bring this into the core repo instead.

Relevant discussion on Discord: https://discord.com/channels/1071029581009657896/1074755895529062531/1075236819597267044

WaldemarLehner avatar Feb 15 '23 16:02 WaldemarLehner

The image in the example should work, since it's served with the "Access-Control-Allow-Origin: *" header. I added image.crossOrigin = 'anonymous' to the Image component and rendering works as normal. This won't work for images without the Access-Control-Allow-Origin header, though.

exdeejay avatar Feb 15 '23 20:02 exdeejay