# alpha-cutout

Adopt this skill: https://proskills.md/skills/other/moh4696-open-3d-skills-skills-alpha-cutout

Page: https://proskills.md/skills/other/moh4696-open-3d-skills-skills-alpha-cutout
This document is the public skill bundle for that page. It contains SKILL.md and every helper file the catalog carries. No login is required.

## File: skills/alpha-cutout/SKILL.md
size: 6918
sha: 96a355a528a761c92598fe440d68c46397268bea
upstream: https://github.com/moh4696/open-3d-skills/blob/main/skills/alpha-cutout/SKILL.md

````text
---
name: alpha-cutout
description: Turns an AI-generated (or filmed) clip of a character or product into a transparent, web-ready animation by shooting it on a flat chroma plate and keying it with per-frame plate measurement and edge unmixing. Delivers stacked-alpha MP4 that plays with transparency in every browser including Safari, VP9 alpha WebM, or keyed WebP frame sequences for scroll scrubbing. Use when an animated subject has to sit over page content, between layers of type, or over a 3D scene, when someone asks for "transparent video", "video with alpha", "remove the background from this clip", or when a generated video has a background that should not be there.
---

# Alpha cutout

Image-to-video models never return alpha. A character that should stand between a giant headline and a sun disc, or a bottle that should float over a page, needs its background removed from every frame, cleanly enough that no fringe shows on any page colour. This skill does it the way a film studio does: shoot the subject on a flat key colour, then pull a matte per frame.

## Tools

| File | What it does |
| --- | --- |
| `scripts/plate.py` | Places a cut-out subject on a key plate with headroom; picks the key colour the subject clashes with least |
| `scripts/key.py` | Keys a clip or still into stacked MP4, VP9 alpha WebM, RGBA WebP frames or PNGs |
| `assets/alpha-video.js` | Plays a stacked-alpha MP4 with transparency on a canvas (WebGL), with `seek()` for scrubbing |

Needs Python 3.9+, `pip install numpy opencv-python-headless pillow`, and ffmpeg on PATH.

## Pipeline

1. **Cut the subject out of the still.** Any background remover works. Check what it dropped: thin objects (a ball in the air, racket strings, hair) often go missing. Put them back by hand.

2. **Make the plate.**

   ```bash
   python3 scripts/plate.py subject.png plate.png --size 1350x1800 --fit 0.8
   ```

   The script reports, for magenta, green and blue, how much of the subject sits close to that key, and picks the cleanest. Magenta is usually right: almost nothing in skin, clothing, product or foliage is pure magenta. Green fails on plants and green products; blue fails on denim and sky. The subject is pasted with a hard edge on purpose: a soft edge pre-mixed with the plate colour teaches the model to draw a fringe.

   Leave room for the motion. A serve needs space above the head; a turn needs space to the side. Match the plate's aspect to the video model's output aspect so nothing gets cropped.

3. **Generate on the plate.** Use the plate as the start frame (and as the end frame for a seamless loop; see `motion-assets`). The prompt must name the plate, or the model invents a background:

   > The background must remain a completely flat, uniform, pure chroma-key magenta (exact RGB 255, 0, 255) filling the entire frame, with no shadows, no floor, no gradient, no vignette and no scenery. Locked-off camera.

   **The prompt wins over the image.** A leftover "on a white background" in the prompt gets you a white background whatever the plate was. Re-read the prompt after every edit.

4. **Key it.**

   ```bash
   python3 scripts/key.py clip.mp4 --png check/ --frame-list 0,40,80,120   # inspect first
   python3 scripts/key.py clip.mp4 --stacked hero-1080.mp4 --width 1080
   python3 scripts/key.py clip.mp4 --stacked hero-720.mp4 --width 720      # phones
   python3 scripts/key.py clip.mp4 --frames serve/lg --width 960 --start 0 --end 78
   ```

   `--png` prints the plate colour it measured in each frame. Models drift the plate: a pure magenta can come back pale pink (250, 178, 243) halfway through while the subject keeps its colours. That is why the key colour is measured per frame from the frame's own border instead of being set once. If the subject touches the top edge, the measurement breaks and the script says so.

   Then, per pixel: alpha comes from how far the pixel's key excess is from the plate's, edge pixels are unmixed against the plate (`F = (C - (1 - a) K) / a`), and surviving spill is clamped. Unmixing matters. A plain colour-key or a clamp toward neutral turns magenta edge blends tan, which reads as a salmon outline on a white shirt; unmixing returns the real edge colour.

5. **Check on three backgrounds**: near-black, the page's light colour, and its loudest brand colour. Composite the `--png` output and look at the edges at 100% and 400%. Look hardest at hair, fingers, thin props, motion blur and the frame where the plate drifted most.

## Fixes

| You see | Do |
| --- | --- |
| A thin outline that follows the subject | `--erode 1` (rarely 2). The model copied a fringe into the artwork |
| Holes in light or shiny parts | That colour is too close to the key. Regenerate on a different key (`plate.py --key`) |
| Pink or green wash over the whole background | The plate gradient is stronger than usual; check the measured plate colours and raise `--solid` slightly |
| A detail changed colour mid-clip (a cap turned green) | Model drift, not a keying fault. Recolour that hue in those frames, or regenerate |
| Coloured line round the subject after compression | Already handled: colour under transparent pixels is the subject's own edge colour pushed outward, so chroma subsampling has nothing foreign to bleed in. `--fill` only sets the colour far from the subject |

## Which output

| Output | Plays in | Use for |
| --- | --- | --- |
| `--stacked` MP4 + `assets/alpha-video.js` | Every browser, Safari and iOS included | Looping heroes, characters, anything that plays |
| `--webm` VP9 alpha | Chrome, Edge, Firefox. Not Safari | A plain `<video>` tag where Safari support is not needed |
| `--frames` WebP sequence | Everywhere | Scroll-scrubbed motion (see `scroll-film`): exact frame per scroll position, no seeking lag |

Stacked alpha doubles the frame height (colour on top, matte below) and a shader recombines them, so it is plain H.264: it decodes in hardware everywhere and costs about 1.3 times the bytes of the colour alone. Ship two widths (1080 and 720) and choose by viewport.

```js
import { AlphaVideo } from "./alpha-video.js";
const hero = new AlphaVideo(document.querySelector("canvas.hero"), innerWidth > 900 ? "hero-1080.mp4" : "hero-720.mp4");
hero.onready = () => document.body.classList.add("hero-ready");
function frame() { hero.draw(); requestAnimationFrame(frame); }
requestAnimationFrame(frame);
```

The player pauses itself off screen and uploads a texture only when the video has moved on. For scrubbing, create it with `{ loop: false, autoplay: false }` and call `hero.seek(seconds)` from your scroll handler.

Serve videos from a server that answers HTTP Range requests (the `frame-capture` skill's serve.py does; `python3 -m http.server` does not), or seeking will not work.

## Reduced motion

With `prefers-reduced-motion: reduce`, show one keyed still (`--png` a good frame, convert to WebP) instead of the loop.
````

## File: skills/alpha-cutout/scripts/key.py
size: 10706
sha: 63ef7f07ed91bd768e17cef79ae52bcfecdf0367
upstream: https://github.com/moh4696/open-3d-skills/blob/main/skills/alpha-cutout/scripts/key.py

```text
#!/usr/bin/env python3
"""Key a generated clip shot on a flat colour plate into real alpha, frame by frame.

    python3 key.py clip.mp4 --stacked out.mp4 [--width 1080]          # H.264 colour over matte, any browser
    python3 key.py clip.mp4 --webm out.webm [--width 720]             # VP9 with alpha (Chrome, Firefox, Edge)
    python3 key.py clip.mp4 --frames out_dir [--width 960 --step 2]   # RGBA WebP sequence + manifest.json
    python3 key.py clip.mp4 --png out_dir --frame-list 0,30,60        # full-size RGBA PNGs to inspect
    python3 key.py image.png --png out_dir                            # stills work too

--key magenta (default) | green | blue. The plate colour is measured per frame from the
frame's own border (top band and upper side columns, where the subject rarely reaches),
because video models drift it: a #FF00FF plate can come back pale pink halfway through.

Matte: the key's "excess" e (magenta: min(R,B) - G; green: G - max(R,B); blue: B - max(R,G))
is high on the plate and low on the subject. With K the frame's plate colour:
    a = (0.97 e_K - e) / (0.97 e_K - solid)    clipped to 0..1
Edges are then unmixed against K, F = (C - (1 - a) K) / a, which recovers the real edge
colour instead of a tinted fringe; whatever spill survives is clamped.

Colour under fully transparent pixels is the subject's own edge colour pushed outward. Video
and lossy WebP subsample chroma, so whatever sits next to an edge bleeds into it: a flat fill
there draws a coloured line round the subject.

Needs: pip install numpy opencv-python-headless pillow; ffmpeg on PATH.
"""
import argparse
import json
import subprocess
from pathlib import Path

import cv2
import numpy as np
from PIL import Image

EXCESS = {
    "magenta": lambda f: np.minimum(f[..., 0], f[..., 2]) - f[..., 1],
    "green": lambda f: f[..., 1] - np.maximum(f[..., 0], f[..., 2]),
    "blue": lambda f: f[..., 2] - np.maximum(f[..., 0], f[..., 1]),
}
# channel a surviving spill shows up in, and the channels whose max caps it at the edge
SPILL = {"magenta": (2, (1, 1)), "green": (1, (0, 2)), "blue": (2, (0, 1))}

def frames(path):
    if Path(path).suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
        yield np.array(Image.open(path).convert("RGB"))
        return
    cap = cv2.VideoCapture(str(path))
    while True:
        ok, bgr = cap.read()
        if not ok:
            break
        yield cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
    cap.release()

def fps_of(path):
    cap = cv2.VideoCapture(str(path))
    fps = cap.get(cv2.CAP_PROP_FPS) or 24
    cap.release()
    return fps

def plate_colour(rgb):
    h, w, _ = rgb.shape
    b = max(4, w // 100)
    ring = np.concatenate([rgb[:b].reshape(-1, 3), rgb[: int(h * 0.7), :b].reshape(-1, 3),
                           rgb[: int(h * 0.7), -b:].reshape(-1, 3)])
    return np.median(ring, axis=0)

def key(rgb, opts):
    excess = EXCESS[opts.key]
    k = plate_colour(rgb)
    k_ex = float(excess(k[None, None].astype(np.float32))[0, 0])
    if k_ex < opts.solid + 8:
        raise SystemExit(f"plate colour {k.round().tolist()} is not a {opts.key} plate (excess {k_ex:.0f}); "
                         f"check --key, or the subject touches the top edge")
    high = 0.97 * k_ex
    f = rgb.astype(np.float32)
    a = np.clip((high - excess(f)) / max(high - opts.solid, 1.0), 0.0, 1.0)
    ka = np.maximum(a, 0.25)[..., None]  # floor keeps compression noise from exploding
    f = np.where((a < 0.999)[..., None], (f - (1 - ka) * k.astype(np.float32)) / ka, f)
    f = np.clip(f, 0, 255)
    a8 = (a * 255).astype(np.uint8)
    a8 = cv2.morphologyEx(a8, cv2.MORPH_OPEN, np.ones((3, 3), np.uint8))
    a8 = cv2.morphologyEx(a8, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8))
    if opts.erode:
        a8 = cv2.erode(a8, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)), iterations=opts.erode)
    a = cv2.GaussianBlur(a8, (0, 0), 0.6).astype(np.float32) / 255.0
    # spill that survives the unmix: magenta shows as excess blue (red is left alone so red
    # subjects keep their edges), green and blue as excess of their own channel
    c, others = SPILL[opts.key]
    cap = np.maximum(f[..., others[0]], f[..., others[1]]) + opts.solid * (2 if opts.key == "magenta" else 1)
    f[..., c] = np.where(a < 0.999, np.minimum(f[..., c], cap), f[..., c])
    return f.astype(np.uint8), a, k

def under_colour(col, a, fill):
    f = col.astype(np.float32)
    w = a.astype(np.float32)
    out = np.empty_like(f)
    out[:] = np.array(fill, np.float32)
    have = np.zeros(a.shape, bool)
    for sigma in (3, 10):
        den = cv2.GaussianBlur(w, (0, 0), sigma)
        num = cv2.GaussianBlur(f * w[..., None], (0, 0), sigma)
        ok = (den > 1e-3) & ~have
        out[ok] = num[ok] / den[ok][..., None]
        have |= ok
    near = cv2.dilate((a > 0.02).astype(np.uint8), np.ones((33, 33), np.uint8)) > 0
    out[~near] = np.array(fill, np.float32)
    return np.where((a > 0.02)[..., None], f, out).astype(np.uint8)

def resize(img, width):
    if not width or img.shape[1] == width:
        return img
    h = round(img.shape[0] * width / img.shape[1] / 2) * 2
    return cv2.resize(img, (width, h), interpolation=cv2.INTER_AREA)

def size_for(clip, width):
    first = next(frames(clip))
    w = (width or first.shape[1]) // 2 * 2
    return w, round(first.shape[0] * w / first.shape[1] / 2) * 2

def pipe(cmd):
    return subprocess.Popen(cmd, stdin=subprocess.PIPE)

def write_stacked(clip, out, opts):
    w, h = size_for(clip, opts.width)
    fps = opts.fps or fps_of(clip)
    proc = pipe(["ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{w}x{h * 2}",
                 "-r", str(fps), "-i", "-", "-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p",
                 "-crf", str(opts.crf), "-preset", "slow", "-movflags", "+faststart", "-an", str(out)])
    n = 0
    for rgb in frames(clip):
        col, a, _ = key(rgb, opts)
        col = cv2.resize(under_colour(col, a, opts.fill_rgb), (w, h), interpolation=cv2.INTER_AREA)
        m = cv2.resize((a * 255).astype(np.uint8), (w, h), interpolation=cv2.INTER_AREA)
        proc.stdin.write(np.vstack([col, np.repeat(m[..., None], 3, axis=2)]).tobytes())
        n += 1
    proc.stdin.close()
    proc.wait()
    print(json.dumps({"out": str(out), "kind": "stacked", "frames": n, "width": w, "height": h, "fps": fps}))

def write_webm(clip, out, opts):
    w, h = size_for(clip, opts.width)
    fps = opts.fps or fps_of(clip)
    proc = pipe(["ffmpeg", "-y", "-loglevel", "error", "-f", "rawvideo", "-pix_fmt", "rgba", "-s", f"{w}x{h}",
                 "-r", str(fps), "-i", "-", "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "0",
                 "-crf", str(opts.crf + 10), "-auto-alt-ref", "0", "-row-mt", "1", "-an", str(out)])
    n = 0
    for rgb in frames(clip):
        col, a, _ = key(rgb, opts)
        rgba = np.dstack([under_colour(col, a, opts.fill_rgb), (a * 255).astype(np.uint8)])
        proc.stdin.write(cv2.resize(rgba, (w, h), interpolation=cv2.INTER_AREA).tobytes())
        n += 1
    proc.stdin.close()
    proc.wait()
    print(json.dumps({"out": str(out), "kind": "webm", "frames": n, "width": w, "height": h, "fps": fps}))

def write_frames(clip, out_dir, opts):
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    picks = {int(x) for x in opts.picks.split(",")} if opts.picks else None
    picked = []
    for i, rgb in enumerate(frames(clip)):
        if picks is not None:
            if i not in picks:
                continue
        elif i < opts.start or (opts.end is not None and i > opts.end) or (i - opts.start) % opts.step:
            continue
        col, a, _ = key(rgb, opts)
        rgba = resize(np.dstack([under_colour(col, a, opts.fill_rgb), (a * 255).astype(np.uint8)]), opts.width)
        Image.fromarray(rgba).save(out_dir / f"{len(picked):03d}.webp", "WEBP", quality=opts.quality, method=6,
                                   alpha_quality=90)
        picked.append(i)
    w, h = Image.open(out_dir / "000.webp").size
    (out_dir / "manifest.json").write_text(json.dumps({"count": len(picked), "width": w, "height": h,
                                                      "pattern": "{i:03d}.webp", "source_frames": picked}))
    print(json.dumps({"out": str(out_dir), "kind": "frames", "count": len(picked), "width": w, "height": h}))

def write_png(clip, out_dir, opts):
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    want = {int(x) for x in opts.frame_list.split(",")}
    for i, rgb in enumerate(frames(clip)):
        if i in want:
            col, a, k = key(rgb, opts)
            Image.fromarray(np.dstack([col, (a * 255).astype(np.uint8)])).save(out_dir / f"f{i:03d}.png")
            print(f"frame {i}: plate {k.round().astype(int).tolist()}, {100 * (a > 0.5).mean():.1f}% subject")

def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("clip")
    ap.add_argument("--stacked", help="H.264 mp4, colour on top, matte below")
    ap.add_argument("--webm", help="VP9 webm with an alpha channel")
    ap.add_argument("--frames", help="directory for an RGBA WebP sequence")
    ap.add_argument("--png", help="directory for full-size RGBA PNGs")
    ap.add_argument("--key", choices=list(EXCESS), default="magenta")
    ap.add_argument("--frame-list", default="0")
    ap.add_argument("--width", type=int)
    ap.add_argument("--fps", type=float)
    ap.add_argument("--start", type=int, default=0)
    ap.add_argument("--end", type=int)
    ap.add_argument("--step", type=int, default=1)
    ap.add_argument("--picks", help="comma list of source frames for --frames")
    ap.add_argument("--quality", type=int, default=82, help="WebP quality")
    ap.add_argument("--crf", type=int, default=20)
    ap.add_argument("--solid", type=float, default=12, help="key excess at or below this is solid subject")
    ap.add_argument("--erode", type=int, default=0, help="shave N px off the matte edge")
    ap.add_argument("--fill", default="808080", help="hex colour far from the subject under alpha 0")
    opts = ap.parse_args()
    opts.fill_rgb = [int(opts.fill[i:i + 2], 16) for i in (0, 2, 4)]
    if not any([opts.stacked, opts.webm, opts.frames, opts.png]):
        ap.error("choose at least one of --stacked, --webm, --frames, --png")
    if opts.stacked:
        write_stacked(opts.clip, opts.stacked, opts)
    if opts.webm:
        write_webm(opts.clip, opts.webm, opts)
    if opts.frames:
        write_frames(opts.clip, opts.frames, opts)
    if opts.png:
        write_png(opts.clip, opts.png, opts)

if __name__ == "__main__":
    main()
```

## File: skills/alpha-cutout/scripts/plate.py
size: 3647
sha: c45b69536fc9325e828c5b4163fc2b36f01fccd8
upstream: https://github.com/moh4696/open-3d-skills/blob/main/skills/alpha-cutout/scripts/plate.py

```text
#!/usr/bin/env python3
"""Put a cut-out subject on a flat key plate, ready to animate with an image-to-video model.

    python3 plate.py subject.png plate.png [--key auto|magenta|green|blue] [--size 1350x1800]
                     [--fit 0.82] [--anchor bottom|center] [--margin 0.04]

subject.png must have alpha (cut it out first: rembg, Apple Vision, Photoshop, any tool).
The subject is scaled to --fit of the plate height and placed on a pure key colour with
room to move: a serve, a jump or a head turn needs headroom the still never had.

--key auto picks the key colour the subject collides with least, and every run prints the
collision report: the share of the subject's opaque pixels whose own key excess is close
to the plate's. Above about 0.5% the key will eat into the subject; pick another key or
recolour those details before generating anything.
"""
import argparse

import numpy as np
from PIL import Image

PLATES = {"magenta": (255, 0, 255), "green": (0, 255, 0), "blue": (0, 0, 255)}
EXCESS = {
    "magenta": lambda f: np.minimum(f[..., 0], f[..., 2]) - f[..., 1],
    "green": lambda f: f[..., 1] - np.maximum(f[..., 0], f[..., 2]),
    "blue": lambda f: f[..., 2] - np.maximum(f[..., 0], f[..., 1]),
}

def collisions(rgba):
    f = rgba[..., :3].astype(np.float32)
    opaque = rgba[..., 3] > 200
    out = {}
    for name, ex in EXCESS.items():
        e = ex(f)[opaque]
        out[name] = (float((e > 60).mean() * 100), float(np.percentile(e, 99.5)) if e.size else 0.0)
    return out

def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("subject")
    ap.add_argument("out")
    ap.add_argument("--key", choices=["auto", *PLATES], default="auto")
    ap.add_argument("--size", default="1350x1800", help="plate size WxH; match the video model's aspect")
    ap.add_argument("--fit", type=float, default=0.82, help="subject height as a share of the plate")
    ap.add_argument("--anchor", choices=["bottom", "center"], default="bottom")
    ap.add_argument("--margin", type=float, default=0.04, help="gap below the subject, share of height")
    args = ap.parse_args()

    im = Image.open(args.subject)
    if im.mode != "RGBA" or im.getchannel("A").getextrema()[0] == 255:
        raise SystemExit("subject has no transparency; cut it out first")
    im = im.crop(im.getchannel("A").getbbox())
    rgba = np.array(im)
    report = collisions(rgba)
    for name, (share, p995) in report.items():
        print(f"{name:8s} {share:5.2f}% of the subject would key out (99.5th pct excess {p995:.0f})")
    key = min(report, key=lambda k: report[k][0]) if args.key == "auto" else args.key
    if report[key][0] > 0.5:
        print(f"warning: {report[key][0]:.2f}% of the subject is close to {key}; the key will bite into it")

    W, H = (int(v) for v in args.size.lower().split("x"))
    s = args.fit * H / im.height
    if im.width * s > W * 0.94:
        s = W * 0.94 / im.width
    im = im.resize((round(im.width * s), round(im.height * s)), Image.LANCZOS)
    x = (W - im.width) // 2
    y = H - im.height - round(args.margin * H) if args.anchor == "bottom" else (H - im.height) // 2
    plate = Image.new("RGBA", (W, H), PLATES[key] + (255,))
    # hard-edged alpha: soft edges pre-blended with the plate colour teach the model a fringe
    a = np.array(im.getchannel("A"))
    im.putalpha(Image.fromarray(np.where(a > 127, 255, 0).astype(np.uint8)))
    plate.alpha_composite(im, (x, y))
    plate.convert("RGB").save(args.out)
    print(f"key {key}: wrote {args.out} {W}x{H}, subject {im.width}x{im.height} at ({x},{y})")

if __name__ == "__main__":
    main()
```

## File: skills/alpha-cutout/assets/alpha-video.js
size: 4710
sha: d5a355f904a5b42de8ab7523d7695f715f5fe71f
upstream: https://github.com/moh4696/open-3d-skills/blob/main/skills/alpha-cutout/assets/alpha-video.js

```text
// Plays a "stacked alpha" MP4 (colour in the top half, matte as grey in the bottom half) with
// real transparency on a <canvas>. Plain H.264 plus a two-line shader works in every browser,
// Safari included, where VP9 alpha does not.
//
//   import { AlphaVideo } from "./alpha-video.js";
//   const hero = new AlphaVideo(document.querySelector("canvas.hero"), "hero-1080.mp4");
//   function frame() { hero.draw(); requestAnimationFrame(frame); }   // or call draw() from your loop
//
// For scroll scrubbing, pass { loop: false, autoplay: false } and call hero.seek(seconds).
// Give the canvas its size with CSS; resize() matches the backing store to it (DPR capped at 2).

const VERT = `
attribute vec2 p;
varying vec2 uv;
void main() {
  uv = vec2(p.x * 0.5 + 0.5, 0.5 - p.y * 0.5);
  gl_Position = vec4(p, 0.0, 1.0);
}`;
const FRAG = `
precision mediump float;
uniform sampler2D tex;
varying vec2 uv;
void main() {
  vec3 c = texture2D(tex, vec2(uv.x, uv.y * 0.5)).rgb;
  float a = texture2D(tex, vec2(uv.x, 0.5 + uv.y * 0.5)).g;
  gl_FragColor = vec4(c * a, a);
}`;

export class AlphaVideo {
  constructor(canvas, src, { loop = true, autoplay = true } = {}) {
    this.canvas = canvas;
    this.ready = false;
    this.visible = true;
    const gl = canvas.getContext("webgl", { premultipliedAlpha: true, alpha: true, antialias: false });
    this.gl = gl;
    if (!gl) return;

    const sh = (type, source) => {
      const s = gl.createShader(type);
      gl.shaderSource(s, source);
      gl.compileShader(s);
      return s;
    };
    const prog = gl.createProgram();
    gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT));
    gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG));
    gl.linkProgram(prog);
    gl.useProgram(prog);

    gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer());
    gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]), gl.STATIC_DRAW);
    const loc = gl.getAttribLocation(prog, "p");
    gl.enableVertexAttribArray(loc);
    gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);

    this.tex = gl.createTexture();
    gl.bindTexture(gl.TEXTURE_2D, this.tex);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

    const v = document.createElement("video");
    Object.assign(v, { muted: true, loop, playsInline: true, autoplay, preload: "auto", crossOrigin: "anonymous" });
    v.setAttribute("muted", "");
    v.setAttribute("playsinline", "");
    v.src = src;
    this.video = v;
    this.lastTime = -1;
    v.addEventListener("loadeddata", () => {
      this.ready = true;
      this.onready && this.onready();
    }, { once: true });
    if (autoplay) v.play().catch(() => {});

    this.resize();
    new ResizeObserver(() => this.resize()).observe(canvas);
    // a looping hero off screen still decodes every frame; pause it
    new IntersectionObserver(([e]) => {
      this.visible = e.isIntersecting;
      if (!autoplay) return;
      this.visible ? this.play() : this.pause();
    }, { rootMargin: "100px" }).observe(canvas);
  }

  resize() {
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    const w = Math.round(this.canvas.clientWidth * dpr), h = Math.round(this.canvas.clientHeight * dpr);
    if (w && h && (this.canvas.width !== w || this.canvas.height !== h)) {
      this.canvas.width = w;
      this.canvas.height = h;
      this.lastTime = -1;
    }
  }

  play() { this.video && this.video.paused && this.video.play().catch(() => {}); }
  pause() { this.video && !this.video.paused && this.video.pause(); }

  // scrubbing: seconds into the clip; draw() picks the new frame up once it has decoded
  seek(t) {
    const v = this.video;
    if (!v || !v.duration) return;
    const target = Math.min(Math.max(t, 0), v.duration - 0.001);
    if (Math.abs(v.currentTime - target) > 0.001 && !v.seeking) v.currentTime = target;
  }

  draw() {
    const gl = this.gl;
    if (!gl || !this.ready || !this.visible || this.video.readyState < 2) return;
    // upload only when the video moved on: texImage2D from video is the expensive part
    if (this.video.currentTime === this.lastTime && this.video.paused) return;
    this.lastTime = this.video.currentTime;
    gl.viewport(0, 0, this.canvas.width, this.canvas.height);
    gl.bindTexture(gl.TEXTURE_2D, this.tex);
    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB, gl.RGB, gl.UNSIGNED_BYTE, this.video);
    gl.clearColor(0, 0, 0, 0);
    gl.clear(gl.COLOR_BUFFER_BIT);
    gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
  }
}
```

## File: skills/alpha-cutout/agents/openai.yaml
size: 136
sha: 69b5830aaaab60e9e02f6b63aa456e9ddc13675a
upstream: https://github.com/moh4696/open-3d-skills/blob/main/skills/alpha-cutout/agents/openai.yaml

```text
interface:
  display_name: "Alpha Cutout"
  short_description: "Key generated clips into transparent video that plays in every browser"
```

## File: README.md
upstream: https://github.com/moh4696/open-3d-skills/blob/main/README.md

````text
<p align="center">
  <picture>
    <source media="(prefers-color-scheme: dark)" srcset="assets/cover-dark.webp" />
    <img src="assets/cover-light.webp" alt="Open 3D Skills, for coding agents. three.js worlds, scroll films, transparent video, cursor rigs, exploded views. One command." width="100%" />
  </picture>
</p>

<p align="center">
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-24292f?style=flat" alt="MIT License" /></a>
  &nbsp;
  <img src="https://img.shields.io/badge/skills-8-e8642c?style=flat" alt="8 skills" />
  &nbsp;
  <img src="https://img.shields.io/badge/agents-Claude%20Code%20%C2%B7%20Codex%20%C2%B7%20Cursor%20%C2%B7%20Gemini%20%C2%B7%20OpenCode-24292f?style=flat" alt="Works with Claude Code, Codex, Cursor, Gemini CLI and OpenCode" />
  &nbsp;
  <a href="https://github.com/Moh4696/open-3d-skills/actions/workflows/check.yml"><img src="https://github.com/Moh4696/open-3d-skills/actions/workflows/check.yml/badge.svg" alt="Checks" /></a>
</p>

**Open 3D Skills** are eight agent skills for building 3D and animated websites: real-time three.js scenes built from code, scroll-scrubbed films, transparent video that plays in Safari, characters that follow the cursor, exploded views of machines, and the AI image and video work behind them. They also teach the agent to check its work in real browsers and to record it as a smooth 60 fps video.

Every technique here was used to build a real site first, and every script was run on real footage before it went in.

<p align="center">
  <img src="examples/motor-take-apart.webp" alt="A code-built electric motor taking itself apart: the housing lifts, the end shields, fan and rotor slide out along the shaft, the casing turns to x-ray, then it all goes back together. Recorded frame by frame with frame-capture." width="100%" />
</p>

<table>
  <tr>
    <td width="50%"><img src="examples/pavilion-golden.webp" alt="A round classical pavilion on a lawn at golden hour, with long shadows and trees" /></td>
    <td width="50%"><img src="examples/pavilion-night.webp" alt="The same pavilion at night under stars, lit from within by a lantern, with lamp posts glowing along the path" /></td>
  </tr>
  <tr>
    <td width="50%"><img src="examples/motor-exploded.webp" alt="The motor fully exploded, each part labelled at the side of the screen with leader lines" /></td>
    <td width="50%"><img src="examples/motor-section.webp" alt="The motor assembled with its casing cut away, showing copper windings and the rotor inside" /></td>
  </tr>
</table>

<sub>The pavilion is the starter scene in <code>threejs-scene</code>; the motor is the demo in <code>exploded-view</code>. Both are built entirely from code: no model files, no textures to download.</sub>

## Install

```bash
npx skills add Moh4696/open-3d-skills
```

That installs with the [`npx skills`](https://github.com/vercel-labs/skills) CLI, the open installer for agent skills. It asks which of the eight skills you want, which agents to install them for (Claude Code, Codex, Cursor, Gemini CLI, OpenCode, Antigravity and dozens more) and whether they are for this project or every project. Start a new agent session afterwards.

```bash
npx skills add Moh4696/open-3d-skills -g -y                       # all skills, all your agents, every project, no questions
npx skills add Moh4696/open-3d-skills --skill threejs-scene       # just one skill
npx skills update                                                  # pull new versions later
```

`npx skills` needs Node 22.20 or newer.

<details>
<summary>Claude Code plugin</summary>

Inside Claude Code:

```
/plugin marketplace add Moh4696/open-3d-skills
/plugin install open-3d-skills@open-3d-skills
```

The skills are namespaced as `open-3d-skills:threejs-scene` and so on, so they never collide with other skills. `/plugin update` upgrades them.

</details>

<details>
<summary>Codex plugin</summary>

```bash
codex plugin marketplace add Moh4696/open-3d-skills
codex plugin add open-3d-skills@open-3d-skills
```

Start a new Codex session afterwards.

</details>

<details>
<summary>Cursor</summary>

The one-line installer links the skills into `~/.cursor/skills`, where Cursor finds personal skills. To use the repo as a Cursor plugin instead, clone it into `~/.cursor/plugins/local/open-3d-skills` and run **Developer: Reload Window**. Teams can import the repo URL as a marketplace under **Dashboard → Plugins & MCPs → Add Marketplace**.

</details>

<details>
<summary>Gemini CLI</summary>

```bash
gemini extensions install https://github.com/Moh4696/open-3d-skills
```

`gemini extensions update` pulls new versions.

</details>

<details>
<summary>Without Node: the shell installer</summary>

```bash
curl -fsSL https://raw.githubusercontent.com/Moh4696/open-3d-skills/main/install.sh | bash
```

It installs all eight skills for every coding agent it finds (Claude Code, Codex, Cursor, Gemini CLI, and OpenCode, which also reads Claude Code's folder). It clones the repo to `~/.open-3d-skills` and links the skills from there, so running it again updates them. From a clone you can choose:

```bash
git clone https://github.com/Moh4696/open-3d-skills && cd open-3d-skills
./install.sh --claude --codex          # only these agents (also --cursor --gemini --opencode --agents)
./install.sh --dir path/to/skills      # any agent that reads a skills folder
./install.sh threejs-scene frame-capture   # only some skills
./install.sh --copy                    # copy instead of symlink (Windows, synced folders)
./install.sh --uninstall               # remove what it installed
```

Each skill is a plain folder with a `SKILL.md`, so any agent that supports Agent Skills can load it.

</details>

## The skills

| Skill | What it does | Ships with |
| --- | --- | --- |
| [`threejs-scene`](skills/threejs-scene) | Real-time 3D sites built from code: buildings, vehicles, machines, landscapes. Sky and sun presets that blend, soft fitted shadows, ambient occlusion, bloom, instancing, profile sweeps, shader patches, camera flights, quality tiers | A complete 60 fps starter scene, and notes on lighting, geometry, shaders and performance |
| [`scroll-film`](skills/scroll-film) | Scroll-scrubbed films from generated clips: chained with start and end frames, holds for captions, phone versions, loading order, reduced motion | `scroll-film.js` engine, a prep script for frames or scrub-ready video, a seam checker |
| [`motion-assets`](skills/motion-assets) | Planning and generating AI images and clips without wasting money: asset lists with prompts and costs up front, one look across assets, model choice by capability, rate limits, logging | A loop checker that finds and fixes loop points, and a paste-back tool for outpainted images |
| [`alpha-cutout`](skills/alpha-cutout) | Transparent video from clips shot on a key plate: per-frame plate measurement, edge unmixing, outputs for every browser including Safari | Plate builder, keyer (stacked-alpha MP4, VP9 alpha, WebP frames), WebGL alpha video player |
| [`cursor-rig`](skills/cursor-rig) | A painted character whose head follows the cursor, built as a grid of poses on one fixed body, with live blinks | Rig builder (alignment, even steps, flow in-betweens) and a spring-driven canvas player |
| [`exploded-view`](skills/exploded-view) | Interactive explainers of how machines work: staged explode, x-ray, section cut, labels with leader lines, picking | `explode.js` and the motor demo above |
| [`site-check`](skills/site-check) | Checks a 3D or animated page in headless Chromium and WebKit at three sizes: frame time, draw calls, errors, blank canvases, overflow, reduced motion | A checker that writes a report and a contact sheet, and a Range-capable local server |
| [`frame-capture`](skills/frame-capture) | Frame-exact 60 fps video of any page by running it on a virtual clock; labelled side-by-side or stacked comparisons | Recorder, comparison builder, local server |

## How they work together

1. **Plan.*
````
