Git Product home page Git Product logo

gifenc's Introduction

gifenc

experimental

A fast and lightweight pure-JavaScript GIF encoder. Features:

  • Supports many standard GIF features: image, animation, transparency
  • Works in browser and Node.js (ESM + CJS)
  • Highly optimized for V8 (150 1024x1024px frames takes about 2.1 seconds with workers in Chrome)
  • Small library footprint (9KB before GZIP)
  • Can be used across multiple web workers for multi-core devices
  • Allows full control over encoding indexed bitmaps & per frame color palette
  • Fast built-in color quantizer based on a port of PnnQuant.js, which is based on "Pairwise Nearest Neighbor Clustering" 1 2 3
  • Fast built-in palette mapping (reducing colors to their nearest paletted index)

This library is a little lower level than something like GIF.js, but gives much better speed (i.e. often more than twice as fast) with similar visual results for many types of images. Because there is currently no dithering support, and because of the current choice of color quantizer, this encoder is probably best suited for simple flat-style vector graphics, rather than photographs or video that might need special handling across frames (e.g. temporal dithering) or better perceptual color quantizers.

Some features that could be explored in a future version:

  • Alternative color quantizers
  • Alternative palette mapping (such as perceptually based)
  • Dithering support
  • WASM-based speed optimizations
  • Optimizations for FireFox
  • Support Interlacing

Example

You can see a simple browser example here.

You can see a more advanced example of this encoder in action inside looom-tools.netlify.app.

Also see ./test/encode_node.js for a pure Node.js example.

Basic code example:

import { GIFEncoder, quantize, applyPalette } from 'https://unpkg.com/gifenc';

// Get your RGBA image into Uint8Array data, such as from canvas
const { data, width, height } = /* ... getImageData() ... */;

// Quantize your colors to a 256-color RGB palette palette
const palette = quantize(data, 256);

// Get an indexed bitmap by reducing each pixel to the nearest color palette
const index = applyPalette(data, palette);

// Create an encoding stream
const gif = GIFEncoder();

// Write a single frame
gif.writeFrame(index, width, height, { palette });

// Write end-of-stream character
gif.finish();

// Get the Uint8Array output of your binary GIF file
const output = gif.bytes();

API

๐Ÿ’ก If you are new to GIF encoding, you might want to read How GIF Encoding Works to better understand the steps involved.

palette = quantize(rgba, maxColors, options = {})

Given the image contained by rgba, a flat Uint8Array or Uint8ClampedArray of per-pixel RGBA data, this method will quantize the total number of colors down to a reduced palette no greater than maxColors.

Options:

  • format (string, default "rgb565") โ€” this is the color format, either "rgb565" (default), "rgb444", or "rgba4444"
    • 565 means 5 bits red, 6 bits green, 5 bits blue (better quality, slower)
    • rgb444 is 4 bits per channel (lower quality, faster)
    • rgba4444 is the same as above but with alpha support
    • if you choose rgba4444, the resulting color table will include alpha channel
  • oneBitAlpha (boolean|number, default false) โ€”ย if alpha format is selected, this will go through all quantized RGBA colors and set their alpha to either 0x00 if the alpha is less than or equal to 127, otherwise it will be set to 0xFF. You can specify a number here instead of a boolean to use a specific 1-bit alpha threshold
  • clearAlpha (boolean, default true) โ€” if alpha format is selected and the quantized color is below clearAlphaThreshold, it will be replaced with clearAlphaColor (i.e. RGB colors with 0 opacity will be replaced with pure black)
  • clearAlphaThreshold (number, default 0) โ€” if alpha and clearAlpha is enabled, and a quantized pixel has an alpha below or equal to this value, its RGB values will be set to clearAlphaColor
  • clearAlphaColor (number, default 0x00) โ€” if alpha and clearAlpha is enabled and a quantized pixel is being cleared, this is the color its RGB cahnnels will be cleared to (typically you will choose 0x00 or 0xff)

The return value palette is an array of arrays, and no greater than maxColors in length. Each array in the palette is either RGB or RGBA (depending on pixel format) such as [ r, g, b ] or [ r, g, b, a ] in bytes.

index = applyPalette(rgba, palette, format = "rgb565")

This will determine the color index for each pixel in the rgba image. The pixel input is the same as the above function: to a flat Uint8Array or Uint8ClampedArray of per-pixel RGBA data.

The method will step through each pixel and determine it's closest pixel in the color table (in euclidean RGB(A) space), and replace the pixel with an index value in the range 0..255. The return value index is a Uint8Array with a length equal to rgba.length / 4 (i.e. 1 byte per pixel).

The method uses palette, which is an array of arrays such as received from the quantize method, and may be in RGB or RGBA depending on your desired format.

const palette = [
  [ 0, 255, 10 ],
  [ 50, 20, 100 ],
  // ...
];

The format is the same as in quantize, and you can choose between opaque (RGB) and semi-transparent (RGBA) formats. You'll likely want to choose the same format you used to quantize your image.

gif = GIFEncoder(opts = {})

Creates a new GIF stream with the given options (for basic usage, you can ignore these).

  • auto (boolean, default true) โ€” in "auto" mode, the header and first-frame metadata (global palette) will be written upon writing the first frame. If set to false, you will be responsible for first writing a GIF header, then writing frames with { first } boolean specified.
  • initialCapacity (number, default 4096) โ€” the number of bytes to initially set the internal buffer to, it will grow as bytes are written to the stream

Once created:

gif.writeFrame(index, width, height, opts = {})

Writes a single frame into the GIF stream, with index (indexed Uint8Array bitmap image), a size, and optional per-frame options:

  • palette (color table array) โ€” the color table for this frame, which is required for the first frame (i.e. global color table) but optional for subsequent frames. If not specified, the frame will use the first (global) color table in the stream.
  • first (boolean, default false) โ€” in non-auto mode, set this to true when encoding the first frame in an image or sequence, and it will encode the Logical Screen Descriptor and a Global Color Table. This option is ignored in auto mode.
  • transparent (boolean, default false) โ€” enable 1-bit transparency for this frame
  • transparentIndex (number, default 0) โ€” if transparency is enabled, the color at the specified palette index will be treated as fully transparent for this frame
  • delay (number, default 0) โ€” the frame delay in milliseconds
  • repeat (number, default 0) โ€” repeat count, set to -1 for 'once', 0 for 'forever', and any other positive integer for the number of repetitions
  • dispose (number, default -1) โ€” advanced GIF dispose flag override, -1 is 'use default'

gif.finish()

Writes the GIF end-of-stream character, required after writing all frames for the image to encode correctly.

gif.bytes()

Gets a slice of the Uint8Array bytes that is underlying this GIF stream. (Note: this incurs a copy)

gif.bytesView()

Gets a direct typed array buffer view into the Uint8Array bytes underlying this GIF stream. (Note: no copy involved, but best to use this carefully).

gif.writeHeader()

Writes a GIF header into the stream, only necessary if you have specified { auto: false } in the GIFEncoder options.

gif.reset()

Resets this GIF stream by simply setting its internal stream cursor (index) to zero, so that subsequent writes will replace the previous data in the underlying buffer.

gif.buffer

A property on the GIF stream that returns the currently backed ArrayBuffer, note this reference may change as the buffer grows in size.

gif.stream

A property on the GIF stream that returns an internal API that holds an expandable buffer and allows writing single or multiple bytes.

// write a single byte to stream
gif.stream.writeByte(0xff);
// write a chunk of bytes to the stream
gif.stream.writeBytes(myTypedArray, offset, byteLength);

index = nearestColorIndex(palette, pixel)

For the given pixel as [r,g,b] or [r,g,b,a] (depending on your pixel format), determines the index (0...N) of the nearest color in your palette array of colors in the same RGB(A) format.

[index, distance] = nearestColorIndexWithDistance(palette, pixel)

Same as above, but returns a tuple of index and distance (euclidean distance squared).

Web Workers

For the best speed, you should use workers to split this work across multiple threads. Compare these encoding speeds with 150 frames of 1024x1024px GIF in Chrome:

  • Main thread only: ~5 seconds
  • Split across 4 workers: ~2 seconds

This library will run fine in a worker with ES support, but there is currently no built-in worker API, and it's up to the developer to implement their own worker architecture and handle bundling.

The simplest architecture, and the one used in my Looom exporter, is to:

  • Send the RGBA pixel data of each frame to one worker amongst a pool of multiple workers
  • In the worker, do quantization, apply palette, and then use GIFEncoder({ auto: false }) to write a 'chunk' of GIF without a header or end-of-stream
  • Send the encoded bytes view back to the main thread, which will store the chunk into a linear array
  • Once all streams have been encoded and their workers responded with encoded chunks, you can write all frames sequentially into a single GIF stream

There is an example of this in ./test/encode_web_workers.html which uses ./test/worker.js. Future versions of this library might include a pre-bundled worker API built-in for easier use.

How GIF Encoding Works

There are generally 3 steps involved, but some applications might be able to skip these or choose a different algorithm for one of the steps, so this library gives you control over each step.

For each frame in your animation (or, just a single frame for still images):

  1. You'll first need to convert RGB(A) pixels from your source graphic/photograph into a reduced color table (palette) of 256 or less RGB colors. The act of reducing thousands of colors into 256 unique colors that still produce good quality results is known as quantization.
  2. Then, you'll need to turn your RGB(A) pixels into an indexed bitmap, basically going through each pixel and finding the nearest index into the color table for that pixel, based on our reduced palette. In gifenc, we call this applying a palette. The result of this is a bitmap image where each pixel is an index integer in the range 0..255 that points to a color in your palette.
  3. Now, we can encode this single frame by writing the indexed bitmap and local palette. This will compress the pixel data with GIF/LZW encoding, and add it to the GIF stream.

There's some situations where you might need to change the way you approach these steps. For example, if you decide to use a single global 256-color palette for a whole animation, you might only need to quantize once, and then applyPalette to each frame by reducing to the same global palette. In some other cases, you might choose to add prequantization or postquantization to speed up and improve the quantization results, or perhaps skip steps #2 and #3 if you already have indexed images. Or, you might choose to use dithering, or perhaps another quantizer entirely.

Running from Source

Git clone this repo, then:

npm install

To run the node test:

node test/encode_node.js

And check test/output/ folder for the result. Or to benchmark with node:

# re-build from source
npm run dist:cjs

# run benchmark
node test/bench_node.js

Benchmarking/profiling is probably easier with Chrome, and this imports the source directly rather than built version:

npm run serve

Now navigate to http://localhost:5000/test/bench_web.html.

Similarly, while serving you can

More to Come

This library is still a WIP, feel free to open an issue to discuss some things.

Credits

The code here has been forked/inspired/remixed from these libraries:

License

MIT, see LICENSE.md for details.

gifenc's People

Contributors

gre avatar gsimone avatar mattdesl avatar p01 avatar sirpepe avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

gifenc's Issues

while serving you can what!?

I was pretty amused how you stop in the middle of a sentence in README.md to announce "more to come"!

image

Perhaps not intentional?

Some colors are being pixelated(?

The examples show better what I'm trying to say:

028f489e-372a-4d15-8b49-b17ce84c3626 29b01c27-8947-45bc-abcf-d2ba015d1774
gifenc result canvas.toDataUrl() result

Code:

function draw(frame?: CanvasImageSource) {
  return new Promise<void>((resolve) => {
    const VIDEO_NATURAL_WIDTH = videoRef?.videoWidth;
    const VIDEO_NATURAL_HEIGHT = videoRef?.videoHeight;
    const VIDEO_NATURAL_ASPECT_RATIO =
      VIDEO_NATURAL_WIDTH / VIDEO_NATURAL_HEIGHT;
    const p = 100;
    const width =
      Math.min(
        ctx.canvas.height * VIDEO_NATURAL_ASPECT_RATIO,
        ctx.canvas.width
      ) - p;
    const height = Math.min(
      width / VIDEO_NATURAL_ASPECT_RATIO,
      ctx.canvas.height
    );
    const left = (ctx.canvas.width - width) / 2;
    const top = (ctx.canvas.height - height) / 2;

    ctx?.drawImage(
      backgroundImageRef,
      0,
      0,
      ctx.canvas.width,
      ctx.canvas.height
    );

    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = "high";
    ctx?.drawImage(frame ?? videoRef, left, top, width, height);
    resolve();
  });
}

export function exportAsGif() {
  const decodeWorker = new DecodeWorker();
  const gifEncoderWorker = new GifEncoderWorker();
  const gif = GIFEncoder({ auto: false });

  decodeWorker.addEventListener("message", async ({ data }) => {
    const { type, ...rest } = data;

    if (type === "frame") {
      const frame: VideoFrame = rest.frame;

      await draw(frame);

      frame.close();

      const uint8 = ctx?.getImageData(0, 0, 1920, 1080).data;

      gifEncoderWorker.postMessage({ type: "encode", frame: uint8 });
    }
  });

  gifEncoderWorker.addEventListener("message", ({ data }) => {
    const { type, ...rest } = data;

    if (type === "encoded") {
      const output = rest.output;

      frames.push(output);
    }
  });

  decodeWorker.postMessage({ type: "start", url: $recording?.url });

  setTimeout(async () => {
    const chunks = await Promise.all(frames);

    gif.writeHeader();

    // Now we can write each chunk
    for (let i = 0; i < chunks.length; i++) {
      gif.stream.writeBytesView(chunks[i]);
    }

    // Finish the GIF
    gif.finish();

    // Close workers
    decodeWorker.terminate();
    gifEncoderWorker.terminate();

    // Return bytes
    const buffer = gif.bytesView();
    const url = URL.createObjectURL(new Blob([buffer], { type: "image/gif" }));
    console.log(url);
  }, 50_000);
}
// gif-encoder.worker.ts

import { GIFEncoder, applyPalette, prequantize, quantize } from "gifenc";

const FORMAT = "rgb565";
const MAX_COLORS = 256;
let isFirstFrame = true;

function onEncodeFrame({ frame }: { frame: Uint8Array | Uint8ClampedArray }) {
  const encoder = GIFEncoder({ auto: false });

  prequantize(frame);

  const palette = quantize(frame, MAX_COLORS, { format: FORMAT });
  const index = applyPalette(frame, palette, FORMAT);

  encoder.writeFrame(index, 1920, 1080, { palette, first: isFirstFrame });

  const output = encoder.bytesView();

  self.postMessage({ type: "encoded", output }, { transfer: [output.buffer] });

  isFirstFrame = false;
}

const MESSAGE_HANLDER = {
  encode: onEncodeFrame,
  default: () => {
    throw new Error("This type of message is not available");
  },
};

type Handlers = keyof typeof MESSAGE_HANLDER;

self.addEventListener("message", (e) => {
  const { type, ...rest }: { type: Handlers } = e.data;
  const handler = MESSAGE_HANLDER[type] ?? MESSAGE_HANLDER.default;

  handler(rest);
});

Issue with rgb565 packing

The function appears to be giving unusual results:

function rgb888_to_rgb565(r, g, b) {
  return ((r << 8) & 0xf800) | ((g << 2) & 0x03e0) | (b >> 3);
}

If you try to index the entire RGB cube into an array using this, you'll end up with holes and some elements not filled in.

const format = "rgb565";
const bincount = format === "rgb444" ? 4096 : 65536;
const index = new Array(bincount);

for (let r = 0; r < 256; r++) {
  for (let g = 0; g < 256; g++) {
    for (let b = 0; b < 256; b++) {
      const idx =
        format === "rgb444"
          ? rgb888_to_rgb444(r, g, b)
          : rgb888_to_rgb565(r, g, b);
      index[idx] = [r, g, b];
    }
  }
}

for (let i = 0; i < index.length; i++) {
  if (index[i] == null) console.warn("hole", i);
}

It appears a fix might involve something like this:

rgb888_to_rgb565(r, g, b) {
  let r5 = r >> 3; // Shift right to drop the least significant 3 bits
  let g6 = g >> 2; // Shift right to drop the least significant 2 bits
  let b5 = b >> 3; // Shift right to drop the least significant 3 bits
  // Combine into a single 16-bit number
  return (r5 << 11) | (g6 << 5) | b5;
}

occasional output issues

I am making a tool for converting gifs to code for use in C/python, and the code it generates is good, as well as displaying it, inline.

I thought it would be helpful to be able to download a gif to save the tweaks a user makes, but it seems like it has some interference with a few images. It works great with most images, but here is an example of it failing:

Here is original:
voltron

and here is the gif that is output by gifenc, after my dithering & processing (notice what looks kinda like lightning when voltrons are dancing)
voltron_dither

The array of canvas-contexts otherwise works fine, and it displays correctly on the preview-canvas, it's just the outputted gif that is messed up. Here is video of the canvas not having that problem:

Screen.Recording.2024-05-25.at.8.36.58.PM.mov

Here is the code I use gifenc with, to download:

const downloadURL = (data, fileName) => {
  const a = document.createElement('a')
  a.href = data
  a.download = fileName
  document.body.appendChild(a)
  a.style.display = 'none'
  a.click()
  a.remove()
}

export function download(frames, filename='anim.gif', delay=500, repeat=0) {
  const gif = GIFEncoder()

  const palette = [
    [0, 0, 0],
    [255, 255, 255]
  ]

  for (const frame of frames) {
    const { data, width, height } = frame.getImageData(0, 0, frame.canvas.width, frame.canvas.height)
    gif.writeFrame(applyPalette(data, palette), width, height, { palette, delay })
  }

  gif.finish()
  const url = window.URL.createObjectURL(new Blob([gif.bytes()]))
  downloadURL(url, filename)
  window.URL.revokeObjectURL(url)
}

I assume the issue is something I am not doing with dispose or something. Any ideas would be very helpful.

Node worker

Does anyone have an example an example of how writeFrame() can be implemented with a node worker? I can't manage to adapt the web worker example to a node worker.

static background, only differences in foreground

I'm trying to generate a series of gifs where there is a static background and an animation in the foreground. I understand that gif has an option for keeping track of just differences between frames. I've been unable to find any gif encoding software that is able to incorporate this capability. Is it something you've considered adding or could provide advice or links to resources that might help me achieve this?

Dispose 2 not working with transparent gifs

Hey!

Great work with the library it's really fast!

I stumbled on a problem recently which is transparent gifs seems to have each frame stack on each other.

I looked at the source code and found out I had to put dispose = 2 to fix that but it seems to not work.

Here is my code, it's a simple worker that take a gif as a data64 url and newDuration.
The goal is simple modify the duration of the gif by adding or removing delay between frames.

async function adjustGifDuration({ src, newDuration }: { src: string; newDuration: number }) {
    // Get frames from gif
    const frames = await gifFrames({
        url: src,
        frames: 'all',
        outputType: 'png',
        cumulative: true
    });

    // Get current duration
    const currentDuration =
        frames.reduce((total: any, frame: any) => total + frame.frameInfo.delay, 0) / 100;

    // Calculate ratio
    const ratio = newDuration / currentDuration;

    // Create encoder
    const encoder = new GIFEncoder();

    // Create palette rgba 256
    const rgbaData = frames[0].getImage().data;
    const palette = quantize(rgbaData, 256, {
        format: 'rgba4444'
    });

    for (let i = 0; i < frames.length; i++) {
        const frame = frames[i];

        // Get frame image
        const image = frame.getImage().data;

        // Apply palette to image
        const index = applyPalette(image, palette, 'rgba4444'); //rgba4444

        // Add frame to encoder
        encoder.writeFrame(index, frame.getImage().width, frame.getImage().height, {
            palette,
            delay: frame.frameInfo.delay * ratio,
            transparent: true,
            transparentIndex: 0,
            dispose: 2
        });

        // Update progress
        postMessage({ type: 'progress', progress: (i / frames.length) * 100 });
    }

    // Write end-of-stream character
    encoder.finish();

    // Get the Uint8Array output of the binary GIF file
    const output = encoder.bytes();

    // Convert the Uint8Array to a Buffer object
    const buffer = Buffer.from(output);

    return buffer;
}

Everything else seems to work fine, but this is a kind of a huge problem for me, would be glad to know if I did something wrong or if there is a problem with the library!

Regards, Nicolas.

Video.sans.titre.Realisee.avec.Clipchamp.5.mp4

Builded version doesn't have "opts" default parameter in quantize function

When using the builded version the function quantize(rgba, maxColors, opts = {}) doesn't have a default parameter for "opts", if you call quantize() without options it breaks the execution:

Cannot read property 'useSqrt' of undefined (opts is undefined, no default parameter)

If i build it manually it doesn't happen, tested with unpkg and repo master dist folder versions.

function quantize(rgba, maxColors, opts) {

Ability to set frame X and Y position

Thanks for a cool encoder - much faster than the others I've tried for Node so far, and very nice that it is as flexible as it is! :)

You can already set a frames width and height independently of the other frames, but the (x,y) position of a frame is hardcoded to (0,0). If would be nice to be able to specify the x and y positions for writeFrame. (for optimization reasons - it becomes faster to generate a GIF where only a small part of the image changes from frame to frame)

It could for example be part of the "opts" parameter, something like:

        encoder.writeFrame(pixels8bit, width, height, {
            ...
            x = 123, 
            y = 456
        });

Ability to cache using rgba8888 in applyPalette?

Hi! Firstly, thanks for this library, it's really useful!

I've noticed some flickering that can happen in outputted gifs. Given images generated from this p5 sketch, I get output gifs like this:

test(14)

I've narrowed this down to the applyPalette function. What I believe is happening is this:

  • applyPalette caches palette indices based on a hash of the color, which depends on the format you pass in
  • Multiple input colors map to the same cache index because the format reduces the number of bits. These colors might not map to the same palette index.
  • The output palette index depends on which of the set of colors resulting in the same cache index is encountered first
  • The first encountered color for one cache index might change from frame to frame, which is causing flickering

I'm dealing with the flickering by effectively writing my own version of applyPalette which caches based on the full 8-bit-per-channel color:

const getIndexedFrame = frame => {
  const paletteCache = {};
  const length = frame.length / 4;
  const index = new Uint8Array(length);
  for (let i = 0; i < length; i++) {
    const key =
      (frame[i * 4] << 24) |
      (frame[i * 4 + 1] << 16) |
      (frame[i * 4 + 2] << 8) |
      frame[i * 4 + 3];
    if (paletteCache[key] === undefined) {
      paletteCache[key] = nearestColorIndex(
        globalPalette,
        frame.slice(i * 4, (i + 1) * 4)
      );
    }
    index[i] = paletteCache[key];
  }
  return index;
};

This results in an output like this:
test(19)

Anyway, just putting my discovery here in case something like this would be helpful to include in the library! An alternate way of dealing with the flickering might be to find the nearest palette index based on the bit-truncated color as opposed to the source color.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.