# Video Effects: guide for AI agents and scripts

> Add film grain, bloom, halation, retro looks such as RGB split and scanlines, and colour adjustments to a video or photo, then export it at full resolution. It's free, with no watermark and no account needed, and runs entirely in your browser: your file is never uploaded.

AI agents and scripts can use it as a post-processing step in their own pipelines, with the same privacy: processing runs in the browser that opens the page, and there is no server API to send media to.

- The editor, the tool itself: https://video-effects.jingfu.design/editor (where settings links open; "the page" and "the studio" below)
- Home page, what the tool is and does: https://video-effects.jingfu.design/ (a link to it with settings in its query, as links were before the editor moved to `/editor`, is redirected to the editor, the query kept)
- This guide: https://video-effects.jingfu.design/index.md (also served for `Accept: text/markdown` on the home page's and the editor's URLs, and at `/editor.md`)
- Settings schema (version 1): https://video-effects.jingfu.design/settings.schema.json
- API version 1, settings version 1
- Updated: 27 October 2026

Ways in: a link with settings (section 1), settings JSON (2), browser automation through stable DOM hooks (3), the `window.videoEffects` API in the page (4), WebMCP tools where the browser offers them (5), a command-line tool that runs the same engine on the user's machine (6), and an MCP server at `/mcp` through which an agent on the user's computer, signed in to the user's account, drives the editor they have open (7).

## For people

1. Open a video or photo: choose a file with the Open button at the top of the settings, drop it on the page or paste it. Once a file is open, the Open and Examples buttons give way: a photo shows its card, which you click to open another file instead, and a video its clips on the timeline, another file opening from the clip menu (the … button beside the clock) or with O. While no file is open, the empty preview and the Examples button also offer examples (three short clips and a photo, generated with Seedance 2.0 on YouArt for this page); the page usually opens with the first of them already playing, and while an example is open, hovering over the picture offers the other example clips and a button that opens a file of your own.
2. Pick a preset in the Presets tab (each thumbnail shows the frame with that look), or add effects one at a time in the Effects tab with its Add effect button, from light and colour, LUT, detail, glow, lens, vignette, stylise, film grain, transform and keying. Each effect is a card of sliders with a switch that turns it off and on, its own Reset and a button that removes it; they apply from top to bottom, and a card dragged by the dots before its name moves its effect earlier or later. To lay a colour look of your own over the picture, add the LUT effect and load a .cube file, or drop one anywhere on the page. To move through a video or compare the result with the original, unfold the bar under the preview with the arrow at its right end: click or drag along the timeline, or turn on Compare (or press C) and drag the split line, the original on one side of it and the result on the other. There a video is a timeline of clips: drag a clip's edge to trim it, drag a clip to move it, add videos with the + at the timeline's end, and split, trim or delete clips from the clip menu or with S, [, ] and Delete. Each clip has a look of its own: the settings are those of the clip under the playhead. After a click in the studio, or with the keyboard focus in it, Space plays or pauses a video.
3. Export, in the Export tab. A video saves as MP4 or WebM and a photo as PNG, JPEG or WebP, at the source's full resolution, to your downloads folder. The same tab copies a link or settings that carry the look, never the file.

## Why I’m building Video Effects

As a content creator, most of the editing I need is straightforward. But many editors bundle in features I rarely use, making simple tasks more complicated and the software more expensive.

I’m building Video Effects around three principles: simple tools, privacy for your media, and paying only for what you need. Your videos and photos are processed on your own device and never uploaded.

## Effects and ranges

Each setting's path is its URL parameter, its key in settings JSON (nested at the dot) and its field in the WebMCP and JavaScript inputs. Values outside the range are clamped, and every value is rounded to its step's precision. At the defaults the image is unchanged.

By default the effects apply in this order, which is not the order the groups below are listed in (a look's `order` changes it: see The order of the effects). Everything up to the fade, and black and white after it, works in linear light, except that sharpening judges edges, the grid pattern and ASCII their cells' brightness, the glitch its scrambled blocks, the chroma key the colours it takes out, and a reversed black and white its negative and the mix with it, on perceptual values; the colour wheels, a loaded LUT, scanlines, grain and the Dither effect apply after the conversion back to sRGB, and the letterbox bars last. The rain and the glitch move with the video's own time (the frame's, snapped to its frame), so a paused frame holds still, the export matches the preview frame for frame, and a photo shows one moment of them:

1. The browser decodes the frame to sRGB colour, which is converted to linear light.
2. Transform: the picture is read where it was moved, scaled and turned to (about its centre, clockwise for positive angles), black outside it, so every effect after it works on the frame as moved and keeps its size in the frame's pixels.
3. Lens distortion: the frame is bent, barrel or pincushion, and scaled so it still fills the frame.
4. RGB split and chromatic aberration: red and blue are read from slightly different places, the RGB split's by the same distance everywhere, the aberration's more toward the edges.
5. Blur, then sharpening: a Gaussian blur in linear light, even or progressive (growing across the frame from sharp to its full strength), then an unsharp mask on brightness, in perceptual values.
6. Rain on glass: drops that bend the picture behind them like small ball lenses, upside down, with a dark rim and a highlight, running down the glass and leaving clear trails, while the glass between them mists over.
7. Pixelate: the picture in big square pixels, each its cell's average colour.
8. Grid pattern: one mark per cell, from the cell's average: dots that grow with its brightness, from a smallest to a largest size, or tiles of its colour between dark lines; over the picture or on black.
9. ASCII: the picture in text characters, one per cell: 0s and 1s at random over the brighter cells, or, chosen by the cell's brightness, a ramp from a dot to @ or shaded blocks; in white or the cell's colour, over the picture or on black.
10. Glitch: bands of rows torn sideways with red and blue pulled apart, and blocks broken off and scrambled, changing with the video's time.
11. Chroma key: the key colour taken out to black where the picture matches it, over a soft edge, mixed in on the sRGB values as editors blend; a colour matches by its chroma (Rec. 709, on the sRGB values, as keyers measure it) toward the key colour's hue over its brightest channel, so a screen's shadows key with its lit parts, and greys never do. What is left of the key colour in the rest, a screen's spill, is taken out as that colour, its light with it, as a keyer's despill does. Or the matte instead: white where the picture stays, black where it is taken out.
12. Exposure, and white balance (temperature and tint), as a gain on each colour channel.
13. Contrast round mid-grey (18%), saturation against Rec. 709 luminance, then vibrance, which strengthens muted colours more than vivid ones and holds back on skin tones.
14. Bloom: the brightest light, over a soft threshold, blurred at several scales and added back.
15. Halation: the same from the highlights, tinted red-orange and added round them rather than over them.
16. Vignette: darkens toward the corners, fitted to the picture's shape (between the letterbox bars, when there are bars).
17. Fade: lifts black toward a matte grey, corners included, leaving white where it was.
18. Black and white: the picture's luminance (Rec. 709, in linear light) in place of its colour, mixed in at its strength, or reversed, as a negative mixed in on the encoded values, as an editor's Invert at part opacity (the luminance turned around on its encoded value, so mid-grey stays).
19. Back to sRGB.
20. Colour wheels: a grading app's lift, gamma and gain, per channel on the sRGB values: the shadows' wheel moves black (white staying), the highlights' multiplies white (black staying) and the midtones' raises the values to a power (black and white staying); each wheel's colour pushes its tones toward a hue in a way that leaves a grey's luma (Rec. 709) as it was, and its brightness moves all three channels alike.
21. LUT: a creative LUT you load (.cube) maps the sRGB values, as a grading app's viewer hands them to it, and is mixed with the image at its strength (lut.amount).
22. Scanlines: dark horizontal lines, multiplied into the sRGB values.
23. Film grain, seeded by the frame, added to the sRGB values: strongest in the midtones, none in pure black or white.
24. Dither: the sRGB values reduced to a few tones per channel, or to a few greys (black and white at two levels), the tones between made of dots (an 8x8 Bayer matrix, or noise that changes with each frame) chosen in linear light, so the picture keeps its light.
25. Letterbox: pure black bars at the top and bottom.
26. Opacity: the picture over black at its opacity, on the sRGB values, as editors blend a clip's. The result is ready for the encoder.

### The order of the effects

A look's `order` lists the effects by id (the studio's `data-effect`), first applied first: by default `transform`, `lensDistortion`, `rgbSplit`, `chromaticAberration`, `blur`, `sharpen`, `rain`, `pixelate`, `pattern`, `ascii`, `glitch`, `chromaKey`, `light`, `bloom`, `halation`, `vignette`, `fade`, `blackWhite`, `colourWheels`, `lut`, `scanlines`, `grain`, `dither`, `letterbox`, `opacity`. In settings JSON it is an array (`"order": ["grain", "light"]`), in a link a comma-separated parameter (`order=grain,light`), and the studio's cards show it top to bottom.

- A partial list reorders only the effects it names: they take, in its order, the places they have in the default order, and every other effect goes to its default place (a partial list is read against the default order, not the look's current one: `["fade", "vignette"]` swaps those two). Unknown ids and repeats are skipped with a warning.
- Presets and `reset: true` put the default order back; a value change keeps the order. `getSettings()` always returns the whole order; links, Copy link, Copy settings and Export JSON carry it only when it isn't the default.
- Orders the renderer draws alike count as the default: the lens, channel, blur and sharpening effects side by side in any order among themselves (but sharpening before blur), and bloom and halation side by side either way. So does a LUT's place while no LUT is loaded.
- Only the effects in use count: a look whose effects in use draw as in their default order renders exactly as it would by default, wherever the others sit, and still matches its preset.
- Each effect works in its own colour space wherever it is: Light and colour, bloom, halation, vignette, fade and black and white in linear light; the colour wheels, the LUT, scanlines, grain, the Dither effect and letterbox on sRGB values. The output's own small dither (±0.5 of a code) and the 8-bit clamp come last, before letterbox bars at the very end, which stay pure black.
- Effects next to each other that change where the picture is read or filter it work together in one read of it, as in the default order: `transform` first, then `lensDistortion`, `rgbSplit`, `chromaticAberration`, `blur` and `sharpen` in any order among themselves (sharpening placed before blur sharpens first), then `rain`, `pixelate`, `pattern`, `ascii`, `glitch` and `chromaKey`, each over the ones before it. One of these placed before an effect it would otherwise follow takes a read of its own: `pixelate` before `rain` puts the rain on the pixels rather than pixelating it, and `ascii` after `grain` draws its characters from the grainy picture. Bloom and halation next to each other glow from the same light; apart, each glows from the picture as it is at its place.
- A look in another order takes a pass or more of the frame's size per break in that pattern, a little more GPU time; the preview and exports render it alike.

### Effects in the studio

The studio shows the settings as effects, a card each, listed in the order they apply, the look's `order` (see The order of the effects): dragging a card by its handle (`ve-effect-handle`) moves its effect earlier or later, and an `order` set any other way (a link, settings, an agent) moves the cards. A card shows while its effect is in use (a value differs from its default, the grain seed aside; for the LUT, a LUT is loaded) or after the visitor adds it with the Add effect button or changes one of its values; a whole new look made in the studio (a preset, Reset all, an example's preset, pasted settings or a studio link, back or forward) drops the cards it doesn't use. Each card has a switch at the right of its header that turns the effect off and on (off, its values sit at their defaults, the grain seed kept, or for the LUT at a strength of 0, until it is turned on again, which puts back the values it had: see `ve-effect-toggle` in the DOM contract), a Reset, which puts its values back, and a remove button, which also takes the card away. Effects are the interface's: settings JSON, links and the APIs don't name them or say whether one is off, and a change made through `window.videoEffects` or WebMCP shows its cards by itself, and turns an effect that was off back on. The ids are the studio's `data-effect`:

| data-effect | Effect | Group | Settings |
| --- | --- | --- | --- |
| `transform` | Transform | Transform | `transform.x`, `transform.y`, `transform.scale`, `transform.rotation` |
| `lensDistortion` | Lens distortion | Lens | `lensDistortion` |
| `rgbSplit` | RGB split | Stylise | `rgbSplit.amount`, `rgbSplit.angle` |
| `chromaticAberration` | Chromatic aberration | Lens | `chromaticAberration` |
| `blur` | Blur | Detail | `blur`, `progressiveBlur.amount`, `progressiveBlur.start`, `progressiveBlur.angle` |
| `sharpen` | Sharpen | Detail | `sharpen.amount`, `sharpen.radius` |
| `rain` | Rain on glass | Lens | `rain.amount`, `rain.size`, `rain.speed`, `rain.fog` |
| `pixelate` | Pixelate | Stylise | `pixelate` |
| `pattern` | Grid pattern | Stylise | `pattern.amount`, `pattern.style`, `pattern.size`, `pattern.minSize`, `pattern.maxSize`, `pattern.background` |
| `ascii` | ASCII | Stylise | `ascii.amount`, `ascii.characters`, `ascii.size`, `ascii.colour`, `ascii.background` |
| `glitch` | Glitch | Stylise | `glitch.amount`, `glitch.size`, `glitch.speed` |
| `chromaKey` | Chroma key | Keying | `chromaKey.amount`, `chromaKey.hue`, `chromaKey.tolerance`, `chromaKey.softness`, `chromaKey.spill`, `chromaKey.matte` |
| `light` | Light and colour | Light and colour | `exposure`, `contrast`, `saturation`, `vibrance`, `temperature`, `tint` |
| `bloom` | Bloom | Glow | `bloom.amount`, `bloom.threshold`, `bloom.radius` |
| `halation` | Halation | Glow | `halation.amount`, `halation.threshold`, `halation.radius` |
| `vignette` | Vignette | Vignette | `vignette.amount`, `vignette.midpoint`, `vignette.roundness`, `vignette.feather` |
| `fade` | Fade | Light and colour | `fade` |
| `blackWhite` | Black and white | Light and colour | `blackWhite.amount`, `blackWhite.reverse` |
| `colourWheels` | Colour wheels | Light and colour | `shadows.hue`, `shadows.amount`, `shadows.brightness`, `midtones.hue`, `midtones.amount`, `midtones.brightness`, `highlights.hue`, `highlights.amount`, `highlights.brightness` |
| `lut` | LUT | LUT | `lut.amount` |
| `scanlines` | Scanlines | Stylise | `scanlines.amount`, `scanlines.size` |
| `grain` | Film grain | Film grain | `grain.amount`, `grain.size`, `grain.seed` |
| `dither` | Dither | Stylise | `dither.amount`, `dither.style`, `dither.colour`, `dither.levels`, `dither.size` |
| `letterbox` | Letterbox | Stylise | `letterbox` |
| `opacity` | Opacity | Transform | `opacity` |

### Light and colour

Exposure, contrast, saturation, vibrance and white balance, a fade that lifts the blacks like an old print, black and white, or its negative, and colour wheels that tint the shadows, midtones and highlights apart.

| Path | Setting | Range | Default | Step | What it does |
| --- | --- | --- | --- | --- | --- |
| `exposure` | Exposure | -3 to 3 stops | 0 stops | 0.05 | Brightens a dark video or tames an overexposed one, in photographic stops, in linear light: +1 doubles the light and -1 halves it. |
| `contrast` | Contrast | -100 to 100 | 0 | 1 | Pushes tones away from mid-grey (18% linear), or flattens them toward it: -100 is flat, 0 unchanged, 100 strong. |
| `saturation` | Saturation | -100 to 100 | 0 | 1 | Colour strength, measured against Rec. 709 luminance: -100 is black and white, 0 unchanged, 100 doubles it. |
| `vibrance` | Vibrance | -100 to 100 | 0 | 1 | Colour strength that works hardest on muted colours and least on vivid ones, and holds back on skin tones: 100 nearly doubles the colour of a muted pixel and barely touches a vivid one; -100 drains the muted colours first. |
| `temperature` | Temperature | -100 to 100 | 0 | 1 | White balance from cooler, blue (negative) to warmer, amber (positive), without changing brightness. |
| `tint` | Tint | -100 to 100 | 0 | 1 | White balance from green (negative) to magenta (positive), without changing brightness. |
| `fade` | Fade | 0 to 100 | 0 (off) | 1 | Lifts the blacks to a matte grey, like a faded print, leaving white where it was. By default it applies after the vignette, so the corners fade too; 100 lifts black to about code 56 of 255 (4% linear light). |
| `blackWhite.amount` | Black and white amount | 0 to 100 | 0 (off) | 1 | How far the picture turns to grey, by its brightness (Rec. 709 luminance in linear light): 0 turns black and white off, 100 leaves no colour. By default it applies after the fade, so the glows, the vignette and the fade turn grey with the picture, and a LUT, scanlines, grain and dither come after it. |
| `blackWhite.reverse` | Black and white brightness | 0 to 1 (whole number) | 0 | 1 | 0 normal; 1 reversed, as a negative: white turns black and black white, the greys turned around on their encoded values, as an image editor's Invert does. |
| `shadows.hue` | Shadows hue | 0 to 360 degrees | 0 degrees | 1 | The colour the shadows are pushed toward, as an angle on the colour wheel: 0 red, 60 yellow, 120 green, 180 cyan, 240 blue and 300 magenta (360 is red again). It does nothing while shadows.amount is 0. |
| `shadows.amount` | Shadows colour | 0 to 100% | 0 (off) | 1 | How far the shadows are pushed toward the hue, a shift that leaves their brightness (Rec. 709 luma) nearly as it was: 0 none; 100 the most, which takes black about a fifth of the way to the pure colour, white staying white. |
| `shadows.brightness` | Shadows brightness | -100 to 100 | 0 | 1 | Lifts black (positive) or lowers it (negative), white staying where it is, as a grading app's lift: 100 takes black to code 51 of 255, and -100 crushes every tone below about code 43 to black. |
| `midtones.hue` | Midtones hue | 0 to 360 degrees | 0 degrees | 1 | The colour the midtones are pushed toward, as an angle on the colour wheel: 0 red, 60 yellow, 120 green, 180 cyan, 240 blue and 300 magenta (360 is red again). It does nothing while midtones.amount is 0. |
| `midtones.amount` | Midtones colour | 0 to 100% | 0 (off) | 1 | How far the midtones are pushed toward the hue, a shift that leaves their brightness (Rec. 709 luma) nearly as it was: 0 none; 100 the most, which takes a mid-grey red to about (155, 120, 120), black and white staying where they are. |
| `midtones.brightness` | Midtones brightness | -100 to 100 | 0 | 1 | Brightens the midtones (positive) or darkens them (negative), black and white staying where they are, as a grading app's gamma: 100 takes mid-grey (code 128 of 255) to about 181, and -100 to about 64. |
| `highlights.hue` | Highlights hue | 0 to 360 degrees | 0 degrees | 1 | The colour the highlights are pushed toward, as an angle on the colour wheel: 0 red, 60 yellow, 120 green, 180 cyan, 240 blue and 300 magenta (360 is red again). It does nothing while highlights.amount is 0. |
| `highlights.amount` | Highlights colour | 0 to 100% | 0 (off) | 1 | How far the highlights are pushed toward the hue, a shift that leaves their brightness (Rec. 709 luma) nearly as it was: 0 none; 100 the most, which takes white toward the pure colour (to about (255, 248, 194) for an orange at 30), black staying black. |
| `highlights.brightness` | Highlights brightness | -100 to 100 | 0 | 1 | Brightens the highlights (positive) or darkens them (negative), black staying where it is, as a grading app's gain: 100 multiplies the encoded values by 1.5, so every tone above about code 170 of 255 turns white, and -100 halves them, taking white to mid-grey. |

### LUT

A colour look of your own from a LUT file (.cube), the kind colourists and LUT packs share, laid over your grade at the strength you choose.

| Path | Setting | Range | Default | Step | What it does |
| --- | --- | --- | --- | --- | --- |
| `lut.amount` | LUT strength | 0 to 100% | 100% | 1 | How much of the loaded LUT's look is applied, mixed on the sRGB-encoded values the LUT reads and writes: 100 is the LUT as made, 50 halfway between the image without it and with it, 0 none. It changes nothing while no LUT is loaded, and presets leave it, and the LUT, as they are. |

### Detail

Sharpening that brings out edges and fine texture, or a soft blur, even across the frame or growing from sharp to soft.

| Path | Setting | Range | Default | Step | What it does |
| --- | --- | --- | --- | --- | --- |
| `sharpen.amount` | Sharpen amount | 0 to 100 | 0 (off) | 1 | Strength of an unsharp mask on brightness (colours aren't sharpened, so edges get no colour fringes), worked out in perceptual (sRGB-encoded) values; 0 turns sharpening off and 100 is strong. |
| `sharpen.radius` | Sharpen radius | 0.5 to 5 px at 1080p | 1 px at 1080p | 0.1 | Size of the detail sharpened (the unsharp mask's blur), in pixels on a 1080-line frame, scaled with the resolution: small values crisp fine texture, larger ones add local contrast. |
| `blur` | Blur | 0 to 100 | 0 (off) | 1 | A soft blur of the frame, in linear light, even unless progressiveBlur.amount makes it grow across the frame; 0 is off and 100 blurs with a radius of about 2% of the frame's shorter side (a Gaussian whose standard deviation is 1% of it). The radius grows faster toward the top of the range, so the low values give fine control. |
| `progressiveBlur.amount` | Progressive blur | 0 to 100% | 0% (even) | 1 | Makes the blur progressive: the share of the frame, along its direction, over which it grows smoothly from sharp to its full strength. 0 blurs the whole frame evenly; 100 grows it across the whole frame. |
| `progressiveBlur.start` | Progressive blur start | 0 to 100% | 0% | 1 | Where a progressive blur begins: the share of the frame kept sharp, from the side it grows away from. Changes nothing while the blur is even. |
| `progressiveBlur.angle` | Progressive blur direction | 0 to 360 degrees | 90 degrees | 1 | Which way a progressive blur grows, counter-clockwise on screen: 90 is sharp at the bottom and blurred at the top, 270 the other way round, 0 blurred toward the right. Changes nothing while the blur is even. |

### Glow

Bloom, a soft glow that spreads from the brightest parts of the frame as strong light does in a lens or the eye, and halation, the red-orange halo film shows round bright highlights.

| Path | Setting | Range | Default | Step | What it does |
| --- | --- | --- | --- | --- | --- |
| `bloom.amount` | Bloom amount | 0 to 100 | 0 (off) | 1 | Strength of the glow; 0 turns bloom off. |
| `bloom.threshold` | Bloom threshold | 0 to 1 (linear luminance) | 0.7 | 0.01 | How bright a pixel must be to glow, in linear-light luminance, from 0 (everything) to 1 (only the source's white). The knee is soft. |
| `bloom.radius` | Bloom radius | 0.5 to 25% of the shorter side | 4% of the shorter side | 0.1 | How far the glow spreads, in percent of the frame's shorter side, so it looks the same at any resolution. |
| `halation.amount` | Halation amount | 0 to 100 | 0 (off) | 1 | Strength of the red-orange halo; 0 turns halation off. |
| `halation.threshold` | Halation threshold | 0 to 1 (linear luminance) | 0.8 | 0.01 | How bright a highlight must be to halate, in linear-light luminance from 0 to 1. The halo shows round the highlight, not over it. |
| `halation.radius` | Halation radius | 0.2 to 10% of the shorter side | 1.5% of the shorter side | 0.1 | How far the halo reaches beyond the highlight, in percent of the frame's shorter side. |

### Lens

The flaws of a simple lens and the glass in front of it: barrel or pincushion distortion, which bends straight lines, colour fringes toward the edges, and rain running down the glass.

| Path | Setting | Range | Default | Step | What it does |
| --- | --- | --- | --- | --- | --- |
| `lensDistortion` | Lens distortion | -100 to 100 | 0 | 1 | Bends straight lines as a simple lens does: negative is barrel distortion (lines bow outward, the middle bulges), positive pincushion (lines bow inward). The picture is scaled so it always fills the frame, with no empty corners: at -100 the middle is magnified 1.3 times. |
| `chromaticAberration` | Chromatic aberration | 0 to 100 | 0 (off) | 1 | Red and blue fringes that grow from nothing at the centre to the frame's edges, as from a simple lens; 100 separates them by 0.5% of the frame's diagonal at the corners. |
| `rain.amount` | Rain amount | 0 to 100 | 0 (off) | 1 | How many drops are on the glass, from a few to a downpour; 0 turns the rain off. Each drop bends the picture behind it like a small lens, catches a highlight on its rim and shows the picture sharp through the mist. |
| `rain.size` | Rain size | 0.5 to 5% of the shorter side | 2.6% of the shorter side | 0.1 | Size of the larger drops, their mean width in percent of the frame's shorter side, so they look the same at any resolution; the smaller droplets scale with them. |
| `rain.speed` | Rain speed | 0 to 100 | 40 | 1 | How fast the larger drops run down the glass, each at its own pace and in fits and starts, leaving a trail of droplets; 0 holds them still. The rain moves with the video's own time, so a paused frame holds still and the export matches the preview; a photo shows one moment of it. |
| `rain.fog` | Rain mist | 0 to 100 | 50 | 1 | How misty the glass is between the drops: 0 is clear glass, 100 a soft blur of about 1% of the frame's shorter side, a little lifted. The drops and the trails they leave stay clear. |

### Vignette

Darkens the frame toward its corners, as many lenses do, which draws the eye to the middle.

| Path | Setting | Range | Default | Step | What it does |
| --- | --- | --- | --- | --- | --- |
| `vignette.amount` | Vignette amount | 0 to 100 | 0 (off) | 1 | How dark the edges get, in linear light; 0 turns the vignette off and 100 takes the corners down about two stops. It only darkens, and with letterbox bars it fits the picture between them. |
| `vignette.midpoint` | Vignette midpoint | 0 to 100 | 50 | 1 | Where the darkening begins, from the centre (0) to the corners (100). |
| `vignette.roundness` | Vignette roundness | -100 to 100 | 0 | 1 | Shape of the vignette: -100 follows the frame's rectangle, 0 is an ellipse fitted to the frame and 100 a circle. |
| `vignette.feather` | Vignette feather | 0 to 100 | 50 | 1 | Softness of the vignette's edge, from hard (0) to very gradual (100). |

### Stylise

Looks borrowed from old screens, the cinema and digital art: red and blue knocked out of line like a worn videotape, the scanlines of a CRT screen, black letterbox bars, big square pixels, a grid of dots or tiles, ASCII characters, digital glitches and dithering to a few tones.

| Path | Setting | Range | Default | Step | What it does |
| --- | --- | --- | --- | --- | --- |
| `rgbSplit.amount` | RGB split amount | 0 to 100 | 0 (off) | 1 | Shifts the red and blue channels apart by the same distance across the whole frame, in one direction, for a glitch or worn-videotape look (chromatic aberration, by contrast, grows from nothing at the centre); 0 is off and 100 separates red from blue by 1.5% of the frame's shorter side. |
| `rgbSplit.angle` | RGB split angle | 0 to 360 degrees | 0 degrees | 1 | Direction of the split, counter-clockwise on screen: 0 moves red right and blue left, 90 red up and blue down. |
| `scanlines.amount` | Scanlines amount | 0 to 100 | 0 (off) | 1 | Dark horizontal lines, as on a CRT screen, applied to the sRGB-encoded image so they look even from shadows to highlights; 0 is off, and at 100 a dark line's centre keeps about 40% of its brightness (a little more when the lines are only a few pixels apart). |
| `scanlines.size` | Scanlines size | 1 to 8 px at 1080p | 3 px at 1080p | 0.1 | Line period (one dark and one bright line) in pixels on a 1080-line frame, scaled with the resolution. Lines closer than 6 of the frame's own pixels are drawn on whole pixel rows (the period rounded, 2 at least), so they never beat into moiré bands; lines finer than 2 pixels can't be drawn, and fade into an even darkening that keeps the light the lines would average to. |
| `letterbox` | Letterbox | 0 to 100 | 0 (off) | 1 | Pure black cinema bars at the top and bottom, added last by default: 0 is off and 100 crops the picture to 2.76:1. In between, the bars' height is in proportion to the amount, so on a 16:9 frame 11 gives 1.85:1, 31 gives 2:1 and 72 gives 2.39:1. A frame already 2.76:1 or wider gets no bars. |
| `dither.amount` | Dither amount | 0 to 100 | 0 (off) | 1 | How much of the dithered picture shows over the picture as it was; 0 turns dithering off and 100 shows only the dithered tones. By default it applies after the grain and before letterbox bars, on the sRGB-encoded picture. |
| `dither.style` | Dither style | 0 to 1 (whole number) | 0 | 1 | How the in-between tones are made: 0 ordered, the regular crosshatch of an 8x8 Bayer matrix, as on old computer screens; 1 noise, an even scatter of dots that changes from frame to frame like grain (the same frame always the same), so a paused frame holds still and the export matches the preview. |
| `dither.colour` | Dither colour | 0 to 1 (whole number) | 0 | 1 | 0 dithers the picture's brightness to grey tones (black and white at two levels); 1 dithers red, green and blue each, to a few colours. |
| `dither.levels` | Dither levels | 2 to 16 (whole number) | 2 | 1 | Tones per channel, from 2 (black and white, or eight colours) to 16; the tones between them are made of dots, so the picture keeps its overall light. |
| `dither.size` | Dither dot size | 1 to 16 px at 1080p | 2 px at 1080p | 0.5 | The size of the dither's dots in pixels on a 1080-line frame, scaled with the resolution and rounded to whole pixels (one at least), so each dot holds one tone and the picture keeps its light at any size: 1 is as fine as a 1080p frame's pixels, larger values give a coarser, retro pattern. |
| `pixelate` | Pixel size | 0 to 120 px at 1080p | 0 (off) | 1 | Big square pixels, each the average colour of the picture it covers, on a grid centred on the frame: their size in pixels on a 1080-line frame, scaled with the resolution; 0 turns pixelation off. |
| `pattern.amount` | Grid pattern amount | 0 to 100 | 0 (off) | 1 | How strongly the pattern is drawn over the picture; 0 turns it off. Tiles at 100 replace the picture; dots are drawn over it (or, with a dark background, in its place). |
| `pattern.style` | Grid pattern style | 0 to 1 (whole number) | 0 | 1 | The mark drawn in each cell: 0 dots, light dots that grow with the brightness of their cell, as a halftone; 1 tiles, each cell filled with its average colour between dark grid lines. (ASCII characters are an effect of their own, ascii.) |
| `pattern.size` | Grid pattern cell size | 4 to 80 px at 1080p | 16 px at 1080p | 1 | The grid's cell size in pixels on a 1080-line frame, scaled with the resolution, on a grid centred on the frame. |
| `pattern.minSize` | Grid pattern min size | 0 to 100% | 0% | 1 | The dots' smallest size, as a share of their cell's width: the size they have in the darkest cells. At 0 the darkest cells have no dot; above 0 every cell has one. Tiles fill their cells whatever this is. |
| `pattern.maxSize` | Grid pattern max size | 0 to 100% | 100% | 1 | The dots' largest size, as a share of their cell's width: the size they have in the brightest cells; at 100 they touch the dots beside them. A max below the min counts as the min. Tiles fill their cells whatever this is. |
| `pattern.background` | Grid pattern background | 0 to 100 | 0 | 1 | How far the picture behind the pattern darkens, at the pattern's strength: 0 keeps it as it is, the pattern over it; 100, with the amount at 100, leaves only the pattern, on black, hiding the picture (tiles at full strength cover it already). |
| `ascii.amount` | ASCII amount | 0 to 100 | 0 (off) | 1 | How strongly the characters are drawn; 0 turns ASCII off. At 100 they are drawn at full strength over the picture (or, with a dark background, in its place). |
| `ascii.characters` | ASCII characters | 0 to 2 (whole number) | 0 | 1 | Which characters draw the picture, in a 5x7 pixel font: 0 binary, 0s and 1s at random over most of the brighter cells; 1 classic, a ramp from a dot through - : + = % * # to @ as the cell brightens (none with less ink than the one before), as in ASCII art; 2 blocks, shades from light to solid. |
| `ascii.size` | ASCII cell size | 6 to 64 px at 1080p | 16 px at 1080p | 1 | The size of each character's cell in pixels on a 1080-line frame, scaled with the resolution, on a grid centred on the frame. |
| `ascii.colour` | ASCII colour | 0 to 1 (whole number) | 0 | 1 | 0 draws the characters in white, brighter in brighter cells; 1 in the colour of the picture under each cell. |
| `ascii.background` | ASCII background | 0 to 100 | 0 | 1 | How far the picture behind the characters darkens, at their strength: 0 keeps it as it is, the characters over it; 100, with the amount at 100, leaves only the characters, on black, as ASCII art. |
| `glitch.amount` | Glitch amount | 0 to 100 | 0 (off) | 1 | How often and how hard the picture glitches: bands torn sideways with their red and blue knocked apart, and blocks broken up and shifted, in bursts; 0 turns the glitch off. |
| `glitch.size` | Glitch size | 2 to 120 px at 1080p | 24 px at 1080p | 1 | Height of a glitched band in pixels on a 1080-line frame, scaled with the resolution; the broken blocks are a few bands across. |
| `glitch.speed` | Glitch speed | 0 to 100 | 50 | 1 | How often the glitch changes, from about twice to thirty times a second; 0 holds one glitch still. It moves with the video's own time, so a paused frame holds still and the export matches the preview. |

### Film grain

Film-like grain, strongest in the midtones. It is seeded by the frame, so a paused frame holds still and the export matches the preview.

| Path | Setting | Range | Default | Step | What it does |
| --- | --- | --- | --- | --- | --- |
| `grain.amount` | Grain amount | 0 to 100 | 0 (off) | 1 | Grain strength, strongest in the midtones; 0 turns grain off. |
| `grain.size` | Grain size | 0.5 to 3 px at 1080p | 1 px at 1080p | 0.05 | Grain size in pixels on a 1080-line frame, scaled with the resolution, so a 4K export has the same grain as a small preview. |
| `grain.seed` | Grain seed | 0 to 2147483647 (whole number) | 0 | 1 | Random seed: change it for a different grain pattern with the same look. The same seed and frame always give the same grain. |

### Transform

Not a look but where the picture sits: moved, scaled or turned in the frame, black where it leaves it, and how much of it shows over black.

| Path | Setting | Range | Default | Step | What it does |
| --- | --- | --- | --- | --- | --- |
| `transform.x` | Transform horizontal position | -100 to 100% | 0% | 0.5 | Moves the picture right (positive) or left, by this share of the frame's width: at 100 it has moved a whole frame width and is out of sight. What it uncovers is black. |
| `transform.y` | Transform vertical position | -100 to 100% | 0% | 0.5 | Moves the picture up (positive) or down, by this share of the frame's height: at 100 it has moved a whole frame height and is out of sight. What it uncovers is black. |
| `transform.scale` | Transform scale | 10 to 400% | 100% | 1 | The picture's size, a share of the frame's, about its centre: under 100 it shrinks with black round it, over 100 it is enlarged and cropped to the frame. |
| `transform.rotation` | Transform rotation | -180 to 180 degrees | 0 degrees | 1 | Turns the picture about its centre, clockwise for positive angles, as Premiere and CapCut turn a clip; the corners it uncovers are black. By default the transform applies before every other effect, so the vignette, the letterbox and the rest stay where the frame has them. |
| `opacity` | Opacity | 0 to 100% | 100% | 1 | How much of the picture shows over black, as a clip's opacity over an empty background in an editor: 100 is all of it, 0 none. Exports have no transparency, so what shows through is black. Applied last by default, after the letterbox, on the sRGB-encoded values, as editors blend. |

### Keying

Chroma key takes a colour out of the picture to black, a green or blue screen's, and the screen's green or blue out of the edges that stay.

| Path | Setting | Range | Default | Step | What it does |
| --- | --- | --- | --- | --- | --- |
| `chromaKey.amount` | Chroma key amount | 0 to 100 | 0 (off) | 1 | How fully the key colour is taken out: 0 turns the chroma key off, 100 takes it out to black wherever it matches. Exports have no transparency, so what is keyed out is black. It keys the picture as read, after the other effects that change where it is read or filter it, and before Light and colour, so the grade and the glows work on what stays. |
| `chromaKey.hue` | Chroma key colour | 0 to 360 degrees | 120 degrees | 1 | The colour taken out, as an angle on the colour wheel: 0 red, 60 yellow, 120 green, 180 cyan, 240 blue and 300 magenta (360 is red again). A green screen is about 120 to 140, a blue one about 220 to 240. |
| `chromaKey.tolerance` | Chroma key tolerance | 0 to 100% | 65% | 1 | How far a colour may be from the key colour and still be taken out: higher takes out paler and duller shades of it too, a screen's creases and shadows, lower only colours as vivid as the key's. Colours far round the wheel from it, yellow or cyan for a green key, and greys never are. |
| `chromaKey.softness` | Chroma key softness | 0 to 100% | 20% | 1 | How gradually the parts taken out fade to black at their edges, so hair and motion blur keep a soft edge rather than a hard cut. |
| `chromaKey.spill` | Chroma key spill | 0 to 100% | 50% | 1 | How much of the key colour is taken out of what stays, the green or blue a screen casts on hair and edges, its light with it, as a keyer's despill does: 0 leaves it, 100 takes out all of it, so a soft edge goes dark rather than green. Only colours near the key colour change. |
| `chromaKey.matte` | Chroma key output | 0 to 1 (whole number) | 0 | 1 | 0 the picture, keyed; 1 the matte: white where the picture stays, black where it is taken out and grey at soft edges, to check the key, or to export as a mask for another editor. |

## Presets

A preset sets every value except `grain.seed` (the look, not the pattern) and `lut.amount`, and never touches a loaded LUT. Presets never change within a settings version: a changed look gets a new id, so stored links and settings keep their meaning.

| id | Name | Look | Values over the defaults | Link |
| --- | --- | --- | --- | --- |
| `none` | None | Every effect off: the original image. | none | https://video-effects.jingfu.design/editor?preset=none |
| `soft-glow` | Soft glow | Dreamy highlights: a wide bloom with slightly softer contrast. | exposure=0.1, contrast=-8, bloom.amount=40, bloom.threshold=0.6, bloom.radius=6 | https://video-effects.jingfu.design/editor?preset=soft-glow |
| `warm-film` | Warm film | A warm colour-negative look: muted colour, a little halation, grain and a light vignette. | contrast=10, saturation=-10, temperature=18, tint=4, halation.amount=30, halation.threshold=0.75, vignette.amount=20, grain.amount=25, grain.size=1.2 | https://video-effects.jingfu.design/editor?preset=warm-film |
| `night-film` | Night film | Tungsten film at night: cool, contrasty, with strong red halos and bloom round the lights. | exposure=-0.15, contrast=12, temperature=-12, bloom.amount=25, halation.amount=60, halation.threshold=0.7, halation.radius=2, vignette.amount=25, grain.amount=22, grain.size=1.1 | https://video-effects.jingfu.design/editor?preset=night-film |
| `black-and-white` | Black and white | Monochrome with firmer contrast, grain and a light vignette. | contrast=18, saturation=-100, vignette.amount=15, grain.amount=25, grain.size=1.1 | https://video-effects.jingfu.design/editor?preset=black-and-white |
| `faded` | Faded | Low contrast and muted colour, slightly lifted, with fine grain. | exposure=0.15, contrast=-25, saturation=-20, grain.amount=10 | https://video-effects.jingfu.design/editor?preset=faded |
| `vintage-lens` | Vintage lens | An old, simple lens: colour fringes at the edges, dark corners and a gentle glow. | contrast=-5, saturation=-8, bloom.amount=20, bloom.threshold=0.65, bloom.radius=5, chromaticAberration=45, vignette.amount=40, vignette.midpoint=40, vignette.feather=70 | https://video-effects.jingfu.design/editor?preset=vintage-lens |
| `retro-vhs` | Retro VHS | A worn videotape on an old TV: red and blue knocked out of line, a soft picture with haloed edges, boosted but faded colour, scanlines and grain. | exposure=0.25, contrast=-8, saturation=10, vibrance=20, tint=5, fade=30, sharpen.amount=40, sharpen.radius=3, blur=35, vignette.amount=15, rgbSplit.amount=30, scanlines.amount=50, scanlines.size=4, grain.amount=18, grain.size=1.4 | https://video-effects.jingfu.design/editor?preset=retro-vhs |
| `cinematic` | Cinematic | Widescreen cinema: 2.39:1 letterbox bars on a 16:9 frame, firmer contrast, a touch of warmth, a light vignette, fine grain and a little sharpening. | contrast=15, vibrance=10, temperature=8, sharpen.amount=20, vignette.amount=20, letterbox=72, grain.amount=12, grain.size=0.8 | https://video-effects.jingfu.design/editor?preset=cinematic |

## LUTs (.cube)

A creative LUT (look-up table) lays a ready-made colour look over the image, as in a grading app. The studio reads `.cube` files, Adobe's and DaVinci Resolve's format, which Resolve, Premiere Pro and Photoshop export and most LUT packs come in; `.3dl` and other formats aren't read.

- **What is read:** `TITLE`, one table, either `LUT_3D_SIZE` (2 to 129 points per side; 33 and 65 are usual) with red varying fastest, or `LUT_1D_SIZE` (2 to 65,536 entries, a curve per channel), `DOMAIN_MIN` / `DOMAIN_MAX` and Resolve's `LUT_3D_INPUT_RANGE` / `LUT_1D_INPUT_RANGE` (inputs outside the range hold the edge's value), comments, blank lines, CRLF line ends and a byte-order mark, in a file of at most 128 MB. A file with both a 1D and a 3D table (a Resolve shaper LUT) is refused.
- **Strict:** every row is exactly three numbers and the table exactly as long as its size says; anything else fails with `invalid_lut` and a message naming the problem and its line, and the LUT in use, if any, stays. An unknown keyword before the table is ignored and listed in `LutInfo.warnings`.
- **Where it applies:** by default after the light and colour, glow, vignette, fade, black and white and the colour wheels, and before scanlines, grain, the Dither effect and the letterbox (a look's `order` can move it: see The order of the effects), always on the sRGB-encoded image (the display-referred input a creative LUT expects), with 3D tables interpolated trilinearly. Its output is clamped to 0 to 1, then mixed with the image by the strength.
- **Strength:** the setting `lut.amount`, 0 to 100 (default 100): 100 is the LUT as made, 50 halfway. It changes nothing while no LUT is loaded. It is the only part of a LUT in a link or settings JSON. While the studio's LUT effect is turned off, `lut.amount` reads 0 (`getSettings()`, the APIs) but the page's URL, Copy link and Copy settings keep the strength set (see `ve-effect-toggle` in the DOM contract).
- **What keeps it:** a loaded LUT stays when another file is opened, and the preview, the preset thumbnails and every export use it; an export keeps the LUT (and the settings) it started with. Presets and `reset: true` change neither the LUT nor its strength. The studio's Reset all removes the LUT and puts the strength back to 100; the LUT card's Reset only puts the strength back, and its remove button ("Remove the LUT effect") does both and takes the card away.
- **Privacy:** the LUT is read in the browser like any file. A link or settings JSON never carries the table, its name or where it came from. A remote LUT is downloaded only by an explicit call, under `open()`'s rules: https with CORS, this site, or a data: or blob: URL, with no credentials and no referrer.
- **In the studio:** the LUT effect's card (`data-effect="lut"`), shown while a LUT is loaded or once the visitor adds it, holds a "Load a LUT (.cube)" button (the input's label) while no LUT is loaded, and once one is, a box with its name and size (`33³`, `1D · 1024`) and any notes, a button that loads another in its place, "Remove LUT", and the Strength slider. A `.cube` file dropped anywhere on the page loads as the LUT rather than opening as media (a clip and a LUT dropped together do both), and so does pasted `.cube` text with a `LUT_3D_SIZE` or `LUT_1D_SIZE` line. A load reports on the status line ("Loaded the LUT …"); a failure goes to the error line with `data-error-code="invalid_lut"` (or the code that applies) and keeps the LUT in use. The Export tab says that a link or settings don't carry the LUT.

```js
// A file the user chose, a URL, or the text itself:
const lut = await window.videoEffects.loadLut(file);
await window.videoEffects.loadLut({ url: "https://example.com/looks/teal-orange.cube" });
await window.videoEffects.loadLut({ text: cubeText, name: "My look.cube" });
// Half strength, then remove it:
await window.videoEffects.applySettings({ lut: { amount: 50 } });
await window.videoEffects.clearLut();
```

With WebMCP, `video_effects_load_lut` takes `{ url }` or `{ text }`, and `video_effects_clear_lut` removes the LUT. `getStatus().lut` describes the loaded LUT (`LutInfo`), or is null.

## Examples

Footage to try the effects on: the videos are hosted in public Vercel Blob storage and served through its CDN; the photo and posters are served by this site. The studio offers them while no file is open, in the empty preview and under the Examples button, and while an example is open (and the timeline holds nothing else), the other example clips (never the photo) at the bottom centre of the preview on hover or keyboard focus (not on a touch screen), beside a button that opens a file of one's own; a click on one downloads it, with its progress shown, and opens it. With a file open there is no Examples button: open an example by its URL (below). Its suggested preset goes on only over the neutral look (or the look the previous example applied), never over a look the visitor set: an effect they turned off, or a LUT they loaded (or are still loading), counts as one. A visitor's page opens with the first clip (`garden-muse`) playing, muted and looping (opened but paused under reduced motion); under automation (`navigator.webdriver`), with `?autoplay=0` in the URL or with Save-Data on, the studio waits empty. The example clips and photo were generated with Seedance 2.0 on YouArt for this page.

| id | Example | What it shows | File | Size | URL | Suggested preset |
| --- | --- | --- | --- | --- | --- | --- |
| `garden-muse` | Garden Muse | A woman with long dark wavy hair, in an ivory linen dress, turns to look over her shoulder under a stone arch hung with leaves and small white flowers, in warm golden side light. | Video, 1920×1080, 6 s, no sound | 2.8 MB (2751305 bytes) | https://mjjwpgsq19yxqkr1.public.blob.vercel-storage.com/video-effects/garden-muse-v1.mp4 | `warm-film` (Warm film) |
| `scarlet-poppies` | Scarlet Poppies | Three backlit scarlet poppies sway against a deep green background, a dew drop on the nearest petal catching the light. | Video, 1920×1080, 6 s, no sound | 2.3 MB (2319667 bytes) | https://mjjwpgsq19yxqkr1.public.blob.vercel-storage.com/video-effects/scarlet-poppies-v1.mp4 | `soft-glow` (Soft glow) |
| `golden-coast` | Golden Coast | Pale linen curtains on a driftwood frame billow over coastal grass at golden hour, the low sun behind them and a wave breaking on dark rocks beyond. | Video, 1920×1080, 6 s, no sound | 4.0 MB (4028121 bytes) | https://mjjwpgsq19yxqkr1.public.blob.vercel-storage.com/video-effects/golden-coast-v1.mp4 | `cinematic` (Cinematic) |
| `neon-street-photo` | Street in the rain | A photo of a rainy street at night: a car passing the lit shopfronts, neon reflected in the wet road. | Photo, 1920×1080 | 200 KB (200370 bytes) | https://video-effects.jingfu.design/examples/neon-street-still-v1.jpg | `vintage-lens` (Vintage lens) |

Open one from script in the page the same way, by its path or its URL, then apply its preset (or any look):

```js
await window.videoEffects.open("https://mjjwpgsq19yxqkr1.public.blob.vercel-storage.com/video-effects/garden-muse-v1.mp4");
await window.videoEffects.applySettings({ preset: "warm-film" });
```

With WebMCP, `video_effects_open_url` takes the same URLs. They are this site's example assets: the browser downloads the videos from Vercel Blob and the photo from this site, with no credentials, and nothing is uploaded.

## 1. A link with settings

Open the page with settings in its query string: https://video-effects.jingfu.design/editor?preset=warm-film&grain.amount=30&grain.seed=7

- The parameter names are the setting paths above (`exposure`, `bloom.amount`, `grain.seed`, …).
- Order: the neutral defaults, then `preset`, then each parameter, then clamping. So the example is Warm film with stronger grain and seed 7.
- A value outside its range is clamped; one that isn't a finite number is ignored. When a setting's parameter repeats, the last one wins; give `preset` and `v` once each. Unknown parameters (`utm_*`, …) are ignored.
- `v=1` is optional; any other `v` is refused (`unsupported_version`).
- The effects' order is one parameter, `order`, a comma-separated list of effect ids (see The order of the effects); a link carries it only when it isn't the default.
- A link needs only `preset` and the values that differ from it. `window.videoEffects.getSettingsUrl()` returns the current look as a link, and the studio's Copy link copies it (while the LUT effect is turned off, with the strength set rather than 0: see `ve-effect-toggle`).
- **A link holds settings only: never media, a file name or a link to media.** There is no `src` parameter; open remote files with an explicit call (sections 4 and 5).

## 2. Settings JSON

The settings are one plain JSON object, version 1, described by a JSON Schema (draft 2020-12) at https://video-effects.jingfu.design/settings.schema.json (its `$id`). The same look as the link above:

```json
{
  "version": 1,
  "preset": "warm-film",
  "grain": {
    "amount": 30,
    "seed": 7
  }
}
```

which expands to these full settings (what `getSettings()` and the WebMCP tools return):

```json
{
  "version": 1,
  "exposure": 0,
  "contrast": 10,
  "saturation": -10,
  "vibrance": 0,
  "temperature": 18,
  "tint": 4,
  "fade": 0,
  "blackWhite": {
    "amount": 0,
    "reverse": 0
  },
  "shadows": {
    "hue": 0,
    "amount": 0,
    "brightness": 0
  },
  "midtones": {
    "hue": 0,
    "amount": 0,
    "brightness": 0
  },
  "highlights": {
    "hue": 0,
    "amount": 0,
    "brightness": 0
  },
  "lut": {
    "amount": 100
  },
  "sharpen": {
    "amount": 0,
    "radius": 1
  },
  "blur": 0,
  "progressiveBlur": {
    "amount": 0,
    "start": 0,
    "angle": 90
  },
  "bloom": {
    "amount": 0,
    "threshold": 0.7,
    "radius": 4
  },
  "halation": {
    "amount": 30,
    "threshold": 0.75,
    "radius": 1.5
  },
  "lensDistortion": 0,
  "chromaticAberration": 0,
  "rain": {
    "amount": 0,
    "size": 2.6,
    "speed": 40,
    "fog": 50
  },
  "vignette": {
    "amount": 20,
    "midpoint": 50,
    "roundness": 0,
    "feather": 50
  },
  "rgbSplit": {
    "amount": 0,
    "angle": 0
  },
  "scanlines": {
    "amount": 0,
    "size": 3
  },
  "letterbox": 0,
  "dither": {
    "amount": 0,
    "style": 0,
    "colour": 0,
    "levels": 2,
    "size": 2
  },
  "pixelate": 0,
  "pattern": {
    "amount": 0,
    "style": 0,
    "size": 16,
    "minSize": 0,
    "maxSize": 100,
    "background": 0
  },
  "ascii": {
    "amount": 0,
    "characters": 0,
    "size": 16,
    "colour": 0,
    "background": 0
  },
  "glitch": {
    "amount": 0,
    "size": 24,
    "speed": 50
  },
  "grain": {
    "amount": 30,
    "size": 1.2,
    "seed": 7
  },
  "transform": {
    "x": 0,
    "y": 0,
    "scale": 100,
    "rotation": 0
  },
  "opacity": 100,
  "chromaKey": {
    "amount": 0,
    "hue": 120,
    "tolerance": 65,
    "softness": 20,
    "spill": 50,
    "matte": 0
  },
  "order": [
    "transform",
    "lensDistortion",
    "rgbSplit",
    "chromaticAberration",
    "blur",
    "sharpen",
    "rain",
    "pixelate",
    "pattern",
    "ascii",
    "glitch",
    "chromaKey",
    "light",
    "bloom",
    "halation",
    "vignette",
    "fade",
    "blackWhite",
    "colourWheels",
    "lut",
    "scanlines",
    "grain",
    "dither",
    "letterbox",
    "opacity"
  ]
}
```

- Only `version` is required. A missing value takes the preset's value, or the neutral default when there is no preset, so `{"version":1}` is the original image.
- Applied to the studio (`applySettings`, `video_effects_apply_settings`), the input starts from the current settings: omitted values keep their value. Order: the current settings, then `reset: true` (neutral, keeping the seed, the LUT strength and a loaded LUT; `clearLut()` removes the LUT), then `preset`, then the values given, then clamping.
- Out-of-range values are clamped and listed in `warnings`; unknown keys and values that aren't finite numbers are ignored with a warning; `$schema` is accepted and ignored.
- Send numbers as JSON numbers and settings nested under their group, as the schema requires. The studio is lenient, and also reads a numeric string (`"1.5"`) as that number and a top-level dotted key (`"bloom.amount"`) as that setting, but a validator will refuse both.
- In the studio, Copy settings copies this JSON (with `$schema`) and Export JSON downloads it as `<file name>-look.json`; pasting such JSON anywhere on the page, or dropping or pasting a `.json` file holding it, applies it as a whole new look.
- A timeline's looks: on two clips or more Export JSON adds `clips`, each clip's look in the timeline's order (`[{ "settings": { … } }, …]`: no file names, as settings never carry the file), the top level being the look of the clip on show. `applySettings` with `clips`, on a timeline, gives each clip the look at its place (read over its own; clips past the list keep theirs, and warnings say which clip they are about); the clip on show takes the top-level look where the list has no entry for it, and with no timeline, or no `clips`, the top-level look applies as always (a `clips` that isn't a list is ignored with a warning). The command line applies one look: it refuses a file with `clips`, unless `--lenient`, which applies the top-level look.
- `warnings` in a reply lists only what that call caused. `getStatus().warnings` holds the open file's notes plus the warnings of the latest settings change; opening another file clears the latter.
- `version` must be 1 when present: a consumer refuses a version it doesn't know (`unsupported_version`), and nothing changes. A breaking change to the format will come as a new version.

## 3. Browser automation (Playwright, Puppeteer)

```js
import { chromium } from "playwright";

// Google Chrome, not Playwright's bundled Chromium: that has no H.264 or AAC.
const browser = await chromium.launch({ channel: "chrome" });
const context = await browser.newContext({ acceptDownloads: true });
const page = await context.newPage();

// Settings ride in the URL; media never does.
await page.goto("https://video-effects.jingfu.design/editor?preset=warm-film&grain.amount=30&grain.seed=7");
// A visitor's browser opens the page playing an example; under automation
// (navigator.webdriver) or with ?autoplay=0 in the URL it waits empty.
await page.locator('[data-testid="ve-studio"][data-state="empty"]').waitFor();

// The file input #ve-file. The file is read in the browser; nothing is uploaded.
await page.getByLabel("Open a video or image", { exact: true }).setInputFiles("input.mp4");
await page.locator('[data-testid="ve-studio"][data-state="ready"]').waitFor({ timeout: 120_000 });

// Optional: fine-tune with JSON. Out-of-range values come back as warnings.
const { warnings } = await page.evaluate(
  (input) => window.videoEffects.applySettings(input),
  { vignette: { amount: 30 } },
);
if (warnings.length) console.warn(warnings);

// Export and save the browser's download. A long video can take minutes.
// The Export button is in the panel's Export tab: select the tab first.
await page.locator('[data-testid="ve-panel-tab"][data-panel="export"]').click();
const downloading = page.waitForEvent("download", { timeout: 0 });
await page.getByRole("button", { name: "Export", exact: true }).click();
const download = await downloading;
await download.saveAs(download.suggestedFilename());
console.log(await page.evaluate(() => window.videoEffects.getStatus().lastExport));

await browser.close();
```

- Use Google Chrome or Microsoft Edge (`channel: "chrome"` or `"msedge"`). Chromium builds without the licensed codecs, Playwright's own among them, can't read or write H.264 or AAC. Firefox can be driven too (Puppeteer supports it): it can't encode AAC, so other sound in an MP4 export becomes Opus, and in our tests on a desktop Mac its WebM export ran slower than real time (with grain on, about nine times the video's length at 1080p), its MP4 export faster.
- The page needs WebGL 2. Headless Chrome uses the GPU where there is one; on a machine without one, WebGL may need `--use-gl=angle --use-angle=swiftshader-webgl --enable-unsafe-swiftshader`.
- Wait on `data-state`, never on timers. `unsupported` means the browser lacks WebGL 2, and `error` that the file or the preview failed (for `gpu_lost`, wait: it returns to `ready` by itself): read `[data-testid="ve-error"]`'s `data-error-code`, or `getStatus().error`.
- To export without the button, call `window.videoEffects.export({ format })` while waiting for the `download` event: it saves to the downloads folder and never opens a dialog. In a visible Chrome window, a second download without a click may ask the user once to allow multiple downloads.
- The settings panel has three tabs, Presets (the presets, Reset all and Import JSON), Effects (`data-panel="adjust"`: the effects shown, selected when the page opens) and Export (the format, Export, Cancel export, Copy link, Copy settings and Export JSON), and only the selected tab's controls are visible: click `[data-testid="ve-panel-tab"][data-panel="presets"]` before clicking a preset or Reset all, `[data-panel="export"]` before using the export controls, and `[data-panel="adjust"]` (Effects) to come back to the effects. `applySettings({ preset })` and `window.videoEffects.export()` need no tab, and are the simpler ways.
- Setting values with `window.videoEffects.applySettings` (section 4) is the simplest way, and needs no tab or card. In the Effects tab the settings are effects, one card each (see Effects in the studio), and only the cards shown have rows: an effect in use (a value off its default, or a LUT loaded) or one added. To drive a row, add its effect first: click `[data-testid="ve-add-effect"]`, then `[data-testid="ve-effect-item"][data-effect="bloom"]`; its card shows (`[data-testid="ve-effect"][data-effect="bloom"]`) and the menu stays open: press Escape to close it, which puts focus on the card's first slider. Then drive the row directly (`[data-testid="ve-control"][data-setting="bloom.amount"]`; Playwright and Puppeteer scroll it into view themselves). Or set a value with `applySettings` and the card appears by itself. A chip per card in the bar above the list (`[data-testid="ve-tab"][data-effect="bloom"]`) scrolls to it. Each card has a switch that turns its effect off and on (`[data-testid="ve-effect-toggle"][data-effect="<effect id>"]`, `aria-checked`; see the DOM contract), a Reset (`[data-testid="ve-reset"][data-effect="<effect id>"]`) and a remove button (`[data-testid="ve-effect-remove"][data-effect="<effect id>"]`); a double-click on a slider resets that value.

### DOM contract

Stable within an API version. Every hook is a `data-testid` (`page.getByTestId(...)`):

| data-testid | What it is |
| --- | --- |
| `ve-studio` | The studio's root, carrying `data-state`, `data-media`, `data-export` and `data-export-progress`. |
| `ve-file-input` | The file input (`#ve-file`, labelled "Open a video or image"): use `setInputFiles` on it. |
| `ve-open` | The Open button, the file input's label, at the top of the settings panel beside the Examples button while nothing is open. Once a file is open it stays in the page, visually hidden, as the input's label for `getByLabel`; the visible way in is then `ve-replace` for a photo, and O with focus in the studio for a video (whose clips the timeline shows). |
| `ve-replace` | On the open photo's card at the top of the settings panel, present while a photo is open (a video has no card: its clips are on the timeline): a button named "Replace <file name>" that opens the same file input (`#ve-file`), to open another file in its place. |
| `ve-preview` | The preview canvas. |
| `ve-frame-menu` | The preview's context menu (role `menu`), while a file is open: a right-click on the picture, or the context-menu key or Shift+F10 with focus on the preview area, opens it in place of the browser's own. For people: scripts take the frame with `exportBlob()` (a photo) or the command line's `frame`. |
| `ve-frame-item` | One item in the preview's context menu, with `data-action`: `"save"` (Save frame as PNG: the frame on show downloads as a PNG at full size with the look on show, never the compare split, named `<file name>-frame-<time>.png`, the time on the timeline as `1.917s` or `1m02.500s`; a photo's `<file name>-effects.png`), `"copy"` (Copy frame: the same PNG on the clipboard, where the browser can write images there), `"play"` (a video), and the toggles `"compare"`, `"fill"` and `"waveform"` (role `menuitemcheckbox`, as the transport bar's buttons and C, F and W have them). Save and copy are disabled while an export runs. |
| `ve-compare` | The compare split's handle (role `slider`, named "Split position"), present only while Compare is on, which it is not when the page opens: turn it on with the Compare button in the transport bar under the picture (role `button`, name "Compare", `aria-pressed`; visible and usable only while the bar is unfolded, see `ve-timeline-toggle`) or C with focus in the studio. |
| `ve-play` | Play or pause. |
| `ve-scrub` | The video's time slider (a range input named "Time", over the timeline). It is in the page whenever a video is open, but visible and usable only while the transport bar is unfolded (see `ve-timeline-toggle`); folded, it is hidden and inert. Set its value while the bar is unfolded, or seek with `window.videoEffects.seek(seconds)` at any time. |
| `ve-clip` | One block per clip on the timeline (under the time slider), in their order, with `data-ve-clip="<clip id>"` and `data-active` on the clip under the playhead (a click on a block puts the playhead in it). A video opens as one clip; the studio can trim clips (drag a block's edge), split them at the playhead, move them (drag a block, or Move earlier and Move later in `ve-clip-menu`), remove them and add videos after them (`ve-add-clips`). They play and export one after another as one video, at the first clip's size (a clip of another shape is fitted inside it, over black), each clip with a look of its own: the settings (`getSettings()`, `applySettings()`, the page's URL, Copy link and Copy settings) are those of the clip under the playhead, and follow it from clip to clip; a video added starts at the neutral look, a split's two parts keep the clip's, and Apply this look to all clips (`ve-clip-menu`) gives one to every clip. `Status.playback` and `window.videoEffects.seek()` are in the timeline's time, which for one untrimmed clip is the file's own; `Status.media` describes the first clip's file. The clips themselves (their files, trims and order) are the interface's: no link, settings JSON or API call carries them, and opening a file starts a new timeline of one clip; settings JSON's `clips` (Export JSON on two clips or more) carries only each clip's look. For people; a script exporting one file needs none of this. |
| `ve-add-clips` | The add button at the right end of the timeline (named "Add clips"), while a video is open: it opens `ve-clip-input`. A drop of videos on the timeline adds them too. |
| `ve-clip-input` | The timeline's own file input (videos only, several at once): `setInputFiles` on it adds the videos to the end of the timeline as clips, in order. |
| `ve-split` | Splits the clip under the playhead there (or S with focus on the timeline), the frame on show starting the second part. Shown from 640 px wide; `ve-clip-menu` has it at any width. |
| `ve-clip-menu` | The clip tools' menu button beside the clock (named "Clip actions"): Split at the playhead, Trim the start or the end to the playhead, Move earlier, Move later, Apply this look to all clips and Delete clip (role `menuitem`), on the clip under the playhead (a click on a block puts the playhead in it), then Open another file, which replaces the timeline as Open does. |
| `ve-cover` | The cover tile at the front of the timeline, before the clips (named "Cover", `data-cover` `"none"`, `"frame"` or `"image"`), while a video is open: it opens a menu (`ve-cover-item`). The cover is what video exports carry as their cover art (MP4's `covr`, a WebM attachment), shown by players and file browsers that read one; with none set, exports carry none. A frame cover is drawn with the look of the clip that plays it, at the export's size, whenever an export runs. Like the clips, it is the interface's: no link, settings JSON or API call carries it, and opening a file drops it. |
| `ve-cover-item` | One item in the cover tile's menu (role `menuitem`), with `data-action`: `"frame"` (the frame on show becomes the cover), `"image"` (opens `ve-cover-input`) and, while there is a cover, `"remove"`. |
| `ve-cover-input` | The cover's own file input (images): `setInputFiles` on it makes that image the cover, redrawn for each export, upright and without any of its metadata (a photo's location, its camera): a PNG as a PNG, anything else as a JPEG. |
| `ve-timeline-toggle` | The button at the right end of the transport bar that folds the bar and the timeline into one row (the clock, play and this button) or unfolds them, with `aria-expanded` and `aria-controls` (the timeline's region). Folding and unfolding animate for about a quarter of a second (at once under reduced motion); folded, sound, Compare, the waveform button, the clip tools and the timeline stay in the page, hidden and inert. For a visitor the bar starts folded and remembers their choice; under automation (`navigator.webdriver`) it starts unfolded, so the scrub slider, sound and Compare are usable at once. |
| `ve-waveform-toggle` | For people, scripts don't need it: the waveform button in the transport bar (while a file is open and the bar unfolded; `aria-pressed`), or W with focus in the studio, folded or not. It shows a waveform scope of the edited picture over the preview's bottom-right corner; it changes nothing in the look or the export. |
| `ve-waveform` | The waveform panel over the preview's bottom-right corner, present while shown, with `data-mode="luma"` (Rec. 709 luma of the display-encoded picture, 0 to 100 %) or `data-mode="parade"` (red, green and blue side by side). For people; it changes nothing. |
| `ve-waveform-mode` | The waveform panel's two mode buttons, with `data-mode="luma"` or `data-mode="parade"` and `aria-pressed` on the one shown. Hidden (`display: none`, the mode kept as chosen) while the preview is too short to hold them with Compare on, as on a phone; the panel's `data-mode` still says the mode. |
| `ve-display-mode` | The Fit / Fill icon button over the preview's top-right corner, while a file is open (named "Fill"): `data-mode` is the current mode, `"fit"` (the whole frame, letterboxed; the default) or `"fill"` (the frame covers the preview, its edges cropped), and `aria-pressed` is true while filling; a click, or F with focus in the studio, switches. Only how the preview shows the frame: exports, the waveform and `window.videoEffects` are unaffected. |
| `ve-control` | One row per setting, with `data-setting="<path>"`, inside its effect's card in the Effects tab: present only while that card is shown (see Effects in the studio). The Colour wheels card has rows for its three brightnesses only (`shadows.brightness`, `midtones.brightness`, `highlights.brightness`): each wheel's hue and colour are set by its disc (`ve-colour-wheel`). |
| `ve-colour-wheel` | In the Colour wheels card, one disc per wheel, with `data-wheel="shadows"`, `"midtones"` or `"highlights"` (role `slider`, named "<Shadows\|Midtones\|Highlights> colour wheel", `aria-valuenow` the hue in degrees, `aria-valuetext` the hue and colour): a drag moves its puck by the pointer's way (with Shift, a quarter as far), its angle the hue and its distance from the centre the colour; focused, the up and down arrow keys change the colour and, once there is some, the left and right arrow keys turn the hue (Shift: ten at a time); a double-click takes it back to the centre. No colour is always hue 0 there. For people: `applySettings({ shadows: { hue, amount } })` sets the same values. |
| `ve-panel-tab` | The settings panel's three tabs, with `data-panel="presets"` (Presets: the presets, Reset all and Import JSON), `data-panel="adjust"` (Effects: the effects shown; selected when the page opens) or `data-panel="export"` (the format, Export, Cancel export, Copy link, Copy settings and Export JSON): click one to show its controls. |
| `ve-tab` | One chip per effect shown, in the bar above the effects, with `data-effect="<effect id>"` and `data-group="<group id>"`: click it to scroll that effect's card into view. The card on show carries `aria-current="true"`. |
| `ve-add-effect` | The "Add effect" button at the end of the bar above the effects: it opens a menu of every effect (role `menu`), a tile each in a grid under its group's name. |
| `ve-effect-item` | One tile per effect in the Add effect menu (role `menuitem`), with `data-effect="<effect id>"`: click it to add that effect, whose card then shows; the menu stays open, the tile now checked and disabled (`aria-disabled="true"`, as every effect already shown is), for the next pick, until Escape (focus then goes to the last card added), the button or a click elsewhere closes it. The arrow keys move across the grid. |
| `ve-effect` | One card per effect shown, with `data-effect="<effect id>"` (role `group`, named by the effect's name), holding its rows; `data-off` is on it while the effect is turned off (see `ve-effect-toggle`). |
| `ve-effect-toggle` | On each effect's card, at the right of its header between Reset and the remove button, with `data-effect="<effect id>"`: a switch (role `switch`, named by the effect's name, `aria-checked`) that turns the effect off and on. Off sets the effect's values in the settings to their off values: every value at its default, the grain seed kept, and for the LUT `lut.amount` 0, the LUT staying loaded. So the preview and exports show the effect off, and `getSettings()` and the APIs (`getSettingsUrl()` included) read its off values, as do the page's URL, Copy link and Copy settings, but for the LUT's strength: while the LUT effect is off, those three carry the strength set (the one its Strength row shows), not the 0 that renders, so after a reload (which forgets the off state, and the LUT) or wherever the link or settings are opened, the next LUT loads at that strength. The card stays, dimmed and marked `data-off`, its controls disabled and showing the values set before, and turning the effect on puts those back. Whether an effect is off is the interface's, like the cards themselves: it is never in the settings, a link, `Status` or the APIs. A reload forgets it, and so do a preset, Reset all, pasted settings or a studio link, and back or forward (the LUT, if it was off, then comes back at the strength it had, unless the new look names a strength, which a preset never does; but back or forward to an entry that differs only by the LUT effect being off, such as the one before a hash navigation within the page, applies nothing, as the URL keeps the strength set, so the LUT stays off); a change to the effect's values through `window.videoEffects` or WebMCP turns it back on with the new values. An example opens without its suggested preset while an effect is off, as over any look the visitor set. |
| `ve-effect-remove` | On each effect's card, with `data-effect="<effect id>"` and named "Remove <effect name>" (the LUT's "Remove the LUT effect"): puts the effect's values back to their defaults (the grain seed aside; for the LUT, it also removes the LUT) and takes the card away. |
| `ve-effect-handle` | On each effect's card, before its name, with `data-effect="<effect id>"` and named "Move <effect name>": a handle that drags the card to another place in the list (or, focused, moves it a place with the up and down arrow keys); disabled, and out of sight, while its card is the only one shown. The cards are the look's processing order, top to bottom, so a move changes the settings' `order` (see The order of the effects), and with it the look. |
| `ve-reset` | On each effect's card, with `data-effect="<effect id>"` and named "Reset <effect name>" (for example "Reset Light and colour"): puts that effect's values back to their defaults (the grain seed aside; for the LUT, only its strength) and keeps the card. Shown only while the effect is on and differs from its defaults. Rows have no reset of their own. |
| `ve-reset-all` | In the Presets tab, inside the first preset tile, None (`[data-testid="ve-preset"][data-preset="none"]`, an empty tile with a reset icon), which is Reset all: a click on either. Back to the original image: resets every setting (the grain seed aside, the LUT strength to 100), removes a loaded LUT and empties the effects list. |
| `ve-preset` | One button per preset in the Presets tab, with `data-preset="<id>"`: a thumbnail of the frame with that look, named by its `aria-label` (role `radio`, `aria-checked` on the current one); the first, None, is named "Reset all" and resets everything (see `ve-reset-all`). |
| `ve-format` | The export format picker, a menu button named "Export format: <current format>": click it, then the option (role `menuitemradio`); or pass `format` to `window.videoEffects.export()`. |
| `ve-export` | The "Export" button. |
| `ve-cancel` | The "Cancel export" button. |
| `ve-status` | The status line, visually hidden (people see it as a toast): progress and notes. Read its text; it is not a live region. |
| `ve-error` | The error line, visually hidden (people see it as a toast), with `data-error-code="<ErrorCode>"`. |
| `ve-copy-link` | Copies the page URL with the current settings (while the LUT effect is turned off, with the strength set rather than 0: see `ve-effect-toggle`). |
| `ve-copy-settings` | Copies the current settings as JSON (while the LUT effect is turned off, with the strength set rather than 0: see `ve-effect-toggle`). |
| `ve-export-json` | Downloads the same settings JSON as a file, `<file name>-look.json` (the open file's name without its extension, or `video-effects-look.json` with nothing open): a normal download. On a timeline of two clips or more it also lists every clip's look (`clips`, see Settings JSON), which Import JSON, a drop or a paste puts back clip by clip. Scripts get the same look from `window.videoEffects.getSettings()` (an object, without `$schema` or `clips`, and with the LUT's strength at 0 while its effect is off). |
| `ve-import-json` | In the Presets tab, the file input of Import JSON (`accept=".json,application/json"`): `setInputFiles` with a settings file, such as Export JSON writes, applies its look as a whole new look, as dropping it on the page does. |
| `ve-example` | One button per example (see Examples), with `data-example="<id>"`: it downloads that example and opens it. Shown while nothing is open, inside the empty preview and in the menu under the Examples button at the top of the settings panel; and while an example is open with nothing else on the timeline, the other example clips (never the photo) in a panel at the preview's bottom centre, shown on hover or keyboard focus (never on a touch screen). With any other file open there is no Examples button: open an example with `window.videoEffects.open(url)` or the `video_effects_open_url` tool (see Examples). |
| `ve-language` | The interface language menu button, at the end of the title row (see Interface languages): click it, then the language (role `menuitemradio`); or open the page with `?lang=`. Test ids are the same in every language. |
| `ve-account` | The account button, before the language menu, with `data-account` `"unknown"`, `"signed-out"` or `"signed-in"`: it opens a dialog to sign in or create an account (see Privacy guarantees). Present only where accounts are set up, and not on screens under 22.5rem wide. Nothing in the studio, the API or the WebMCP tools needs an account; only the MCP endpoint does (section 7), and an agent signs in there through its person, never in the page. |
| `ve-agent-toggle` | In the account dialog of the editor, while an AI agent is connected to the visitor's account over MCP (see section 7): Pause or Resume, with `data-action` `"pause"` or `"resume"`, whether this tab takes the agent's calls (paused, they go to another editor, or answer `no_editor`). The account button (`ve-account`) then carries `data-agent`: `"listening"`, `"working"` (a call running) or `"paused"`. |
| `ve-agent-client` | In the account dialog's AI agents section (signed in), a tab for each MCP client, with `data-client` `"claude-code"`, `"codex"`, `"cursor"` or `"other"`: its panel shows what to run or paste to add the MCP endpoint (see section 7), with a copy button. |
| `ve-lut-input` | The LUT file input (`#ve-lut-file`, named "Load a LUT (.cube)", accepting `.cube`), always in the page, whether or not the LUT card is shown: use `setInputFiles` on it, or `window.videoEffects.loadLut(file)`. The LUT card shows once a LUT is loaded. |
| `ve-lut-remove` | In the LUT card, the "Remove LUT" button, present while a LUT is loaded: it removes the LUT and leaves the card; or call `window.videoEffects.clearLut()`. |
| `ve-welcome` | The tour a visitor sees on their first visit to the editor (a modal `<dialog>` over the whole window, named "Welcome to Video Effects", with `data-step` the step on show): what the tool does, in four steps (`"effects"`, `"timeline"`, `"agents"`, `"privacy"`), the first two lighting the part of the editor they are about (the Add effect button; the transport bar and timeline, unfolded for the step) with a card beside it, the other two a card at the window's centre: the step's name and what it does (for the agents' step, a button that copies a prompt for an AI agent, pointing it at the editor and this guide), a segment per step, Skip, Back, and Next, Start editing on the last; the left and right arrow keys move between steps. Never shown under automation (`navigator.webdriver`), so scripts never meet it; once closed (Skip, Start editing, ×, Escape or a click outside the card) it doesn't show again in that browser (localStorage `video-effects:welcome`). A script that overrides `navigator.webdriver` to see a visitor's page closes it with `ve-welcome-start` or Escape, or sets that key first; while it is open the page behind it is inert. |
| `ve-welcome-start` | The tour's Skip button, which closes it on any step (as Start editing does on the last). |

Attributes on `[data-testid="ve-studio"]`:

- `data-state`: the studio's state, below.
- `data-media`: `"none"`, `"video"` or `"image"`.
- `data-export`: how the last export went: `"idle"` (no export yet), `"running"` (an export is running), `"done"` (the last export finished), `"cancelled"` (the last export was cancelled), `"error"` (the last export failed).
- `data-export-progress`: 0 to 1 while exporting, updated at most about four times a second.

The file input accepts `video/mp4,video/quicktime,video/webm,video/x-matroska,.mp4,.mov,.m4v,.webm,.mkv,image/jpeg,image/png,image/webp,image/avif,.jpg,.jpeg,.png,.webp,.avif`.

### States

| data-state | Meaning |
| --- | --- |
| `checking` | Probing the browser (WebGL 2, WebCodecs, encoders). |
| `unsupported` | No WebGL 2, so nothing can be shown; `error` says why. |
| `empty` | Ready for a file. |
| `loading` | Opening a file. |
| `ready` | A file is open; settings apply live. |
| `exporting` | An export is running; `Status.export` has its progress. |
| `error` | The open file or the preview failed (see `error`); opening another file recovers, and a lost graphics context (`gpu_lost`) returns to `ready` by itself within a few seconds. |

## 4. In-page JavaScript: `window.videoEffects`

Present while the page is open, once it has loaded its script (wait with `page.waitForFunction(() => window.videoEffects)`). Every method that changes something resolves once the preview shows the change; failures reject with an `Error` whose `code` is an error code below. Nothing returns pixels or file bytes, except `exportBlob`, to script running in the page.

```ts
interface VideoEffectsApi {
  readonly apiVersion: 1;
  readonly settingsVersion: 1;
  readonly schemaUrl: string; // absolute URL of the settings schema
  readonly guideUrl: string; // absolute URL of this guide
  readonly presets: readonly PresetInfo[];
  ready(): Promise<Status>; // resolves once state has left "checking"
  getStatus(): Status;
  subscribe(listener: (status: Status) => void): () => void; // called on every status change; returns an unsubscribe function
  getSettings(): VideoEffectsSettings;
  applySettings(input: SettingsInput | string): Promise<ApplyResult>; // an object or a JSON string; resolves once the preview shows it
  getSettingsUrl(): string; // the page URL that reopens the studio with the current settings
  open(source: File | Blob | string, options?: { name?: string }): Promise<MediaInfo>; // a File or Blob, or a URL downloaded into the browser first (https with CORS, a path on this site such as an example's, data: or blob:; no credentials)
  play(): Promise<void>;
  pause(): void;
  seek(seconds: number): Promise<void>; // resolves once that frame is on screen
  export(options?: ExportOptions): Promise<ExportResult>; // renders at full resolution and saves to the downloads folder, without a dialog
  exportBlob(options?: ExportOptions): Promise<{ blob: Blob; result: ExportResult }>; // returns the file to the calling page script instead of saving it; the blob stays readable until the next export, the next open or the page closing
  cancelExport(): Promise<void>;
  loadLut(source: LutSource): Promise<LutInfo>; // a .cube File or Blob, { url } (downloaded like open()'s URLs) or { text }, with an optional name; see LUTs
  clearLut(): Promise<void>; // removes the LUT; lut.amount stays
}

type StudioState = "checking" | "unsupported" | "empty" | "loading" | "ready" | "exporting" | "error";
type ExportFormat = "mp4" | "webm" | "png" | "jpeg" | "webp";
type AudioOutcome =
  | "copied" // the source's audio, unchanged (audioNote warns when some players can't play it in this container)
  | "transcoded" // converted: in an MP4 to AAC, or to Opus where the browser can't encode AAC; in a WebM to Opus
  | "none" // the source had no audio
  | "dropped"; // couldn't be kept; audioNote says why
type ErrorCode = "unsupported_browser" | "unsupported_file" | "unsupported_codec" | "export_unsupported" | "decode_failed" | "encode_failed" | "too_large_for_gpu" | "out_of_memory" | "gpu_lost" | "fetch_failed" | "cors_blocked" | "no_media" | "busy" | "cancelled" | "invalid_input" | "invalid_lut" | "unsupported_version";

interface Status {
  apiVersion: 1;
  settingsVersion: 1;
  state: StudioState;
  capabilities: Capabilities | null; // null while state is "checking"
  media: MediaInfo | null;
  playback: { playing: boolean; currentTime: number; duration: number } | null;
  export: ExportProgress | null; // while an export runs
  lastExport: ExportResult | { cancelled: true } | null;
  error: { code: ErrorCode; message: string } | null;
  lut: LutInfo | null; // the loaded LUT, applied at settings.lut.amount; null for none
  warnings: string[]; // the open file's notes plus the warnings of the latest settings change (opening a file clears the latter)
}
interface Capabilities {
  webgl2: boolean;
  floatBuffers: boolean; // 16-bit float buffers; false falls back to 8-bit
  maxTextureSize: number;
  webcodecs: boolean; // VideoDecoder and VideoEncoder exist
  webmcp: boolean; // WebMCP tools are registered on this page
  formats: ExportFormat[]; // what this browser can write; video probed at 1080p
  saveToDisk: boolean; // exports can stream into a file on disk (Chrome, Edge)
}
interface MediaInfo {
  kind: "video" | "image";
  name: string; // as given; never written to the URL
  width: number; // upright pixel size, after rotation
  height: number;
  sizeBytes: number;
  mimeType: string;
  durationSeconds?: number;
  frameRate?: number | null; // null: a variable frame rate it couldn't guess
  videoCodec?: string | null;
  audioCodec?: string | null;
  hdr?: boolean; // HDR (HLG/PQ), BT.2020 or possibly camera Log. HDR is converted to standard range and BT.2020 to sRGB colour, so highlights or saturated colours may clip; Log is not converted. Display P3 is converted to sRGB but not flagged
}
interface ApplyResult {
  settings: VideoEffectsSettings; // the full settings now in effect
  warnings: string[]; // what this call clamped or ignored (values are also rounded to their step's precision, unreported)
  url: string; // the page URL with these settings in its query
}
interface ExportOptions {
  format?: ExportFormat | "auto"; // auto (default): MP4, but WebM for WebM and for MKV holding VP8, VP9 or AV1; an image keeps its format where the browser can write it, else PNG
  quality?: number; // JPEG and WebP, 0.5 to 1 (default 0.92)
  filename?: string; // no path; the extension is set to match the format
}
interface ExportProgress {
  phase: "preparing" | "encoding" | "finalizing";
  progress: number; // 0 to 1, only ever increasing; 1 once the file is saved
  format: ExportFormat;
}
interface ExportResult {
  filename: string;
  mimeType: string;
  sizeBytes: number;
  width: number;
  height: number;
  durationSeconds?: number;
  audio: AudioOutcome;
  audioNote?: string; // a sentence on the audio when it was converted, dropped, or kept in a form some players can't play
  elapsedSeconds: number;
  savedTo: "download" | "file" | "none"; // file: streamed into a file the visitor chose; none: not saved (exportBlob)
  warnings: string[];
}
interface PresetInfo {
  id: string; // a preset id from the table above
  name: string;
  description: string;
}
interface LutInfo {
  name: string; // the file's TITLE, else its name, else "LUT"
  fileName: string | null; // as given, or a URL's last path segment; null for text without a name
  kind: "3d" | "1d"; // a 3D table, or one curve per channel
  size: number; // points per side (3D) or entries (1D)
  domain: { min: [number, number, number]; max: [number, number, number] }; // the input range per channel, 0 to 1 unless the file says otherwise
  warnings: string[]; // what was ignored in the file (keywords this tool doesn't read)
}
type LutSource = File | Blob | { url: string; name?: string } | { text: string; name?: string };

// VideoEffectsSettings: the version 1 settings object (see the schema).
// SettingsInput: any subset of it, plus optional preset, reset and version.
```

Export through the API and keep the download:

```js
const [download, result] = await Promise.all([
  page.waitForEvent("download", { timeout: 0 }),
  page.evaluate(() => window.videoEffects.export({ format: "mp4" })),
]);
await download.saveAs(result.filename);
```

## 5. WebMCP tools

Where the browser exposes WebMCP (`document.modelContext`), the page registers these tools, and unregisters them when it closes. WebMCP is experimental: today that means Chrome with its WebMCP flag turned on (`chrome://flags/#enable-webmcp-testing`). This site has no origin-trial token, so Chrome without the flag doesn't expose it here, and Safari and Firefox don't implement it. A tool call can't carry a file: to use one on the user's device, ask the user to open it with the Open button (once a file is open: a photo's card, which replaces it; with a video, Open another file in the clip menu beside the clock, or O) or drop it on the page, or use `video_effects_open_url` for a file on the web. While an export runs, every tool but `video_effects_get_status`, `video_effects_get_settings` and `video_effects_cancel_export` answers `{ ok: false, error: { code: "busy" } }`: wait for the export or cancel it (`window.videoEffects` refuses only `open`, `export` and `exportBlob` then).

### `video_effects_get_status`: Video Effects status

Report what the Video Effects studio is doing: browser support (WebGL 2, WebCodecs, which export formats this browser can write), the open file's details, playback, export progress, the last export and the last error.

- Returns: The Status object (see window.videoEffects.getStatus()).
- Annotations: `readOnlyHint`, `untrustedContentHint`
- Input schema:

```json
{
  "type": "object",
  "properties": {},
  "additionalProperties": false
}
```

### `video_effects_get_settings`: Read effect settings

Return the current look as a version 1 settings object, the presets available, and a link that reopens the studio with these settings.

- Returns: { settings, presets, url, schemaUrl }
- Annotations: `readOnlyHint`
- Input schema:

```json
{
  "type": "object",
  "properties": {},
  "additionalProperties": false
}
```

### `video_effects_apply_settings`: Change effect settings

Change the look. Optionally start from a preset (or reset to neutral), then override individual values. Omitted values keep their current value; out-of-range values are clamped and listed in warnings. Resolves once the preview has redrawn, with the full settings now in effect and a link that reopens the studio with them.

- Returns: { ok, settings, warnings, url }
- Input schema:

```json
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "$schema": {
      "type": "string",
      "description": "Optional; ignored (a settings file may point at its schema)."
    },
    "version": {
      "const": 1,
      "description": "Optional; must be 1 when present."
    },
    "reset": {
      "type": "boolean",
      "default": false,
      "description": "Start from neutral (every effect off; the grain seed, the LUT strength and a loaded LUT kept) before applying the rest. To remove a LUT too, clear it (clearLut(), video_effects_clear_lut)."
    },
    "preset": {
      "enum": [
        "none",
        "soft-glow",
        "warm-film",
        "night-film",
        "black-and-white",
        "faded",
        "vintage-lens",
        "retro-vhs",
        "cinematic"
      ],
      "description": "Start from this look; the values given override it. Presets set every value except grain.seed and lut.amount (and never touch a loaded LUT), and never change within a settings version: none (Every effect off: the original image.); soft-glow (Dreamy highlights: a wide bloom with slightly softer contrast.); warm-film (A warm colour-negative look: muted colour, a little halation, grain and a light vignette.); night-film (Tungsten film at night: cool, contrasty, with strong red halos and bloom round the lights.); black-and-white (Monochrome with firmer contrast, grain and a light vignette.); faded (Low contrast and muted colour, slightly lifted, with fine grain.); vintage-lens (An old, simple lens: colour fringes at the edges, dark corners and a gentle glow.); retro-vhs (A worn videotape on an old TV: red and blue knocked out of line, a soft picture with haloed edges, boosted but faded colour, scanlines and grain.); cinematic (Widescreen cinema: 2.39:1 letterbox bars on a 16:9 frame, firmer contrast, a touch of warmth, a light vignette, fine grain and a little sharpening.)."
    },
    "exposure": {
      "type": "number",
      "minimum": -3,
      "maximum": 3,
      "default": 0,
      "description": "Brightens a dark video or tames an overexposed one, in photographic stops, in linear light: +1 doubles the light and -1 halves it. Unit: stops."
    },
    "contrast": {
      "type": "number",
      "minimum": -100,
      "maximum": 100,
      "default": 0,
      "description": "Pushes tones away from mid-grey (18% linear), or flattens them toward it: -100 is flat, 0 unchanged, 100 strong."
    },
    "saturation": {
      "type": "number",
      "minimum": -100,
      "maximum": 100,
      "default": 0,
      "description": "Colour strength, measured against Rec. 709 luminance: -100 is black and white, 0 unchanged, 100 doubles it."
    },
    "vibrance": {
      "type": "number",
      "minimum": -100,
      "maximum": 100,
      "default": 0,
      "description": "Colour strength that works hardest on muted colours and least on vivid ones, and holds back on skin tones: 100 nearly doubles the colour of a muted pixel and barely touches a vivid one; -100 drains the muted colours first."
    },
    "temperature": {
      "type": "number",
      "minimum": -100,
      "maximum": 100,
      "default": 0,
      "description": "White balance from cooler, blue (negative) to warmer, amber (positive), without changing brightness."
    },
    "tint": {
      "type": "number",
      "minimum": -100,
      "maximum": 100,
      "default": 0,
      "description": "White balance from green (negative) to magenta (positive), without changing brightness."
    },
    "fade": {
      "type": "number",
      "minimum": 0,
      "maximum": 100,
      "default": 0,
      "description": "Lifts the blacks to a matte grey, like a faded print, leaving white where it was. By default it applies after the vignette, so the corners fade too; 100 lifts black to about code 56 of 255 (4% linear light)."
    },
    "blackWhite": {
      "type": "object",
      "additionalProperties": false,
      "description": "The picture turned to greys by its brightness, at the strength you choose, or reversed, as a negative.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How far the picture turns to grey, by its brightness (Rec. 709 luminance in linear light): 0 turns black and white off, 100 leaves no colour. By default it applies after the fade, so the glows, the vignette and the fade turn grey with the picture, and a LUT, scanlines, grain and dither come after it."
        },
        "reverse": {
          "type": "integer",
          "enum": [
            0,
            1
          ],
          "minimum": 0,
          "maximum": 1,
          "default": 0,
          "description": "0 normal; 1 reversed, as a negative: white turns black and black white, the greys turned around on their encoded values, as an image editor's Invert does."
        }
      }
    },
    "shadows": {
      "type": "object",
      "additionalProperties": false,
      "description": "The shadows' colour wheel, a grading app's lift: a colour and a brightness for the dark tones, black moving most and white staying where it is.",
      "properties": {
        "hue": {
          "type": "number",
          "minimum": 0,
          "maximum": 360,
          "default": 0,
          "description": "The colour the shadows are pushed toward, as an angle on the colour wheel: 0 red, 60 yellow, 120 green, 180 cyan, 240 blue and 300 magenta (360 is red again). It does nothing while shadows.amount is 0. Unit: degrees."
        },
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How far the shadows are pushed toward the hue, a shift that leaves their brightness (Rec. 709 luma) nearly as it was: 0 none; 100 the most, which takes black about a fifth of the way to the pure colour, white staying white. Unit: %."
        },
        "brightness": {
          "type": "number",
          "minimum": -100,
          "maximum": 100,
          "default": 0,
          "description": "Lifts black (positive) or lowers it (negative), white staying where it is, as a grading app's lift: 100 takes black to code 51 of 255, and -100 crushes every tone below about code 43 to black."
        }
      }
    },
    "midtones": {
      "type": "object",
      "additionalProperties": false,
      "description": "The midtones' colour wheel, a grading app's gamma: a colour and a brightness for the middle tones, black and white staying where they are.",
      "properties": {
        "hue": {
          "type": "number",
          "minimum": 0,
          "maximum": 360,
          "default": 0,
          "description": "The colour the midtones are pushed toward, as an angle on the colour wheel: 0 red, 60 yellow, 120 green, 180 cyan, 240 blue and 300 magenta (360 is red again). It does nothing while midtones.amount is 0. Unit: degrees."
        },
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How far the midtones are pushed toward the hue, a shift that leaves their brightness (Rec. 709 luma) nearly as it was: 0 none; 100 the most, which takes a mid-grey red to about (155, 120, 120), black and white staying where they are. Unit: %."
        },
        "brightness": {
          "type": "number",
          "minimum": -100,
          "maximum": 100,
          "default": 0,
          "description": "Brightens the midtones (positive) or darkens them (negative), black and white staying where they are, as a grading app's gamma: 100 takes mid-grey (code 128 of 255) to about 181, and -100 to about 64."
        }
      }
    },
    "highlights": {
      "type": "object",
      "additionalProperties": false,
      "description": "The highlights' colour wheel, a grading app's gain: a colour and a brightness for the bright tones, white moving most and black staying where it is.",
      "properties": {
        "hue": {
          "type": "number",
          "minimum": 0,
          "maximum": 360,
          "default": 0,
          "description": "The colour the highlights are pushed toward, as an angle on the colour wheel: 0 red, 60 yellow, 120 green, 180 cyan, 240 blue and 300 magenta (360 is red again). It does nothing while highlights.amount is 0. Unit: degrees."
        },
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How far the highlights are pushed toward the hue, a shift that leaves their brightness (Rec. 709 luma) nearly as it was: 0 none; 100 the most, which takes white toward the pure colour (to about (255, 248, 194) for an orange at 30), black staying black. Unit: %."
        },
        "brightness": {
          "type": "number",
          "minimum": -100,
          "maximum": 100,
          "default": 0,
          "description": "Brightens the highlights (positive) or darkens them (negative), black staying where it is, as a grading app's gain: 100 multiplies the encoded values by 1.5, so every tone above about code 170 of 255 turns white, and -100 halves them, taking white to mid-grey."
        }
      }
    },
    "lut": {
      "type": "object",
      "additionalProperties": false,
      "description": "A creative look-up table (a .cube file) applied to the graded image. The table itself is loaded separately (window.videoEffects.loadLut, the WebMCP tool video_effects_load_lut, or the studio's LUT control) and is never part of the settings or a link: only its strength is.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 100,
          "description": "How much of the loaded LUT's look is applied, mixed on the sRGB-encoded values the LUT reads and writes: 100 is the LUT as made, 50 halfway between the image without it and with it, 0 none. It changes nothing while no LUT is loaded, and presets leave it, and the LUT, as they are. Unit: %."
        }
      }
    },
    "sharpen": {
      "type": "object",
      "additionalProperties": false,
      "description": "An unsharp mask on brightness, which raises the contrast of edges and fine detail.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "Strength of an unsharp mask on brightness (colours aren't sharpened, so edges get no colour fringes), worked out in perceptual (sRGB-encoded) values; 0 turns sharpening off and 100 is strong."
        },
        "radius": {
          "type": "number",
          "minimum": 0.5,
          "maximum": 5,
          "default": 1,
          "description": "Size of the detail sharpened (the unsharp mask's blur), in pixels on a 1080-line frame, scaled with the resolution: small values crisp fine texture, larger ones add local contrast. Unit: px at 1080p."
        }
      }
    },
    "blur": {
      "type": "number",
      "minimum": 0,
      "maximum": 100,
      "default": 0,
      "description": "A soft blur of the frame, in linear light, even unless progressiveBlur.amount makes it grow across the frame; 0 is off and 100 blurs with a radius of about 2% of the frame's shorter side (a Gaussian whose standard deviation is 1% of it). The radius grows faster toward the top of the range, so the low values give fine control."
    },
    "progressiveBlur": {
      "type": "object",
      "additionalProperties": false,
      "description": "Makes the blur grow across the frame, from sharp to its full strength, rather than even: where it starts, over how much of the frame it grows, and toward which side.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "Makes the blur progressive: the share of the frame, along its direction, over which it grows smoothly from sharp to its full strength. 0 blurs the whole frame evenly; 100 grows it across the whole frame. Unit: %."
        },
        "start": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "Where a progressive blur begins: the share of the frame kept sharp, from the side it grows away from. Changes nothing while the blur is even. Unit: %."
        },
        "angle": {
          "type": "number",
          "minimum": 0,
          "maximum": 360,
          "default": 90,
          "description": "Which way a progressive blur grows, counter-clockwise on screen: 90 is sharp at the bottom and blurred at the top, 270 the other way round, 0 blurred toward the right. Changes nothing while the blur is even. Unit: degrees."
        }
      }
    },
    "bloom": {
      "type": "object",
      "additionalProperties": false,
      "description": "A soft glow that spreads from the brightest parts of the frame, as strong light does in a lens or the eye.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "Strength of the glow; 0 turns bloom off."
        },
        "threshold": {
          "type": "number",
          "minimum": 0,
          "maximum": 1,
          "default": 0.7,
          "description": "How bright a pixel must be to glow, in linear-light luminance, from 0 (everything) to 1 (only the source's white). The knee is soft. Unit: linear luminance."
        },
        "radius": {
          "type": "number",
          "minimum": 0.5,
          "maximum": 25,
          "default": 4,
          "description": "How far the glow spreads, in percent of the frame's shorter side, so it looks the same at any resolution. Unit: % of the shorter side."
        }
      }
    },
    "halation": {
      "type": "object",
      "additionalProperties": false,
      "description": "The red-orange halo film shows round bright highlights, from light reflecting back through the film base.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "Strength of the red-orange halo; 0 turns halation off."
        },
        "threshold": {
          "type": "number",
          "minimum": 0,
          "maximum": 1,
          "default": 0.8,
          "description": "How bright a highlight must be to halate, in linear-light luminance from 0 to 1. The halo shows round the highlight, not over it. Unit: linear luminance."
        },
        "radius": {
          "type": "number",
          "minimum": 0.2,
          "maximum": 10,
          "default": 1.5,
          "description": "How far the halo reaches beyond the highlight, in percent of the frame's shorter side. Unit: % of the shorter side."
        }
      }
    },
    "lensDistortion": {
      "type": "number",
      "minimum": -100,
      "maximum": 100,
      "default": 0,
      "description": "Bends straight lines as a simple lens does: negative is barrel distortion (lines bow outward, the middle bulges), positive pincushion (lines bow inward). The picture is scaled so it always fills the frame, with no empty corners: at -100 the middle is magnified 1.3 times."
    },
    "chromaticAberration": {
      "type": "number",
      "minimum": 0,
      "maximum": 100,
      "default": 0,
      "description": "Red and blue fringes that grow from nothing at the centre to the frame's edges, as from a simple lens; 100 separates them by 0.5% of the frame's diagonal at the corners."
    },
    "rain": {
      "type": "object",
      "additionalProperties": false,
      "description": "Rain drops on a pane of glass in front of the picture: each drop bends the light through it, the larger ones run down and leave a clear trail, and the glass between them mists over.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How many drops are on the glass, from a few to a downpour; 0 turns the rain off. Each drop bends the picture behind it like a small lens, catches a highlight on its rim and shows the picture sharp through the mist."
        },
        "size": {
          "type": "number",
          "minimum": 0.5,
          "maximum": 5,
          "default": 2.6,
          "description": "Size of the larger drops, their mean width in percent of the frame's shorter side, so they look the same at any resolution; the smaller droplets scale with them. Unit: % of the shorter side."
        },
        "speed": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 40,
          "description": "How fast the larger drops run down the glass, each at its own pace and in fits and starts, leaving a trail of droplets; 0 holds them still. The rain moves with the video's own time, so a paused frame holds still and the export matches the preview; a photo shows one moment of it."
        },
        "fog": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 50,
          "description": "How misty the glass is between the drops: 0 is clear glass, 100 a soft blur of about 1% of the frame's shorter side, a little lifted. The drops and the trails they leave stay clear."
        }
      }
    },
    "vignette": {
      "type": "object",
      "additionalProperties": false,
      "description": "Darkens the frame's edges toward the corners.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How dark the edges get, in linear light; 0 turns the vignette off and 100 takes the corners down about two stops. It only darkens, and with letterbox bars it fits the picture between them."
        },
        "midpoint": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 50,
          "description": "Where the darkening begins, from the centre (0) to the corners (100)."
        },
        "roundness": {
          "type": "number",
          "minimum": -100,
          "maximum": 100,
          "default": 0,
          "description": "Shape of the vignette: -100 follows the frame's rectangle, 0 is an ellipse fitted to the frame and 100 a circle."
        },
        "feather": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 50,
          "description": "Softness of the vignette's edge, from hard (0) to very gradual (100)."
        }
      }
    },
    "rgbSplit": {
      "type": "object",
      "additionalProperties": false,
      "description": "Red and blue shifted apart by the same distance everywhere, in one direction: the glitch or worn-videotape look.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "Shifts the red and blue channels apart by the same distance across the whole frame, in one direction, for a glitch or worn-videotape look (chromatic aberration, by contrast, grows from nothing at the centre); 0 is off and 100 separates red from blue by 1.5% of the frame's shorter side."
        },
        "angle": {
          "type": "number",
          "minimum": 0,
          "maximum": 360,
          "default": 0,
          "description": "Direction of the split, counter-clockwise on screen: 0 moves red right and blue left, 90 red up and blue down. Unit: degrees."
        }
      }
    },
    "scanlines": {
      "type": "object",
      "additionalProperties": false,
      "description": "Dark horizontal lines, as on a CRT screen.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "Dark horizontal lines, as on a CRT screen, applied to the sRGB-encoded image so they look even from shadows to highlights; 0 is off, and at 100 a dark line's centre keeps about 40% of its brightness (a little more when the lines are only a few pixels apart)."
        },
        "size": {
          "type": "number",
          "minimum": 1,
          "maximum": 8,
          "default": 3,
          "description": "Line period (one dark and one bright line) in pixels on a 1080-line frame, scaled with the resolution. Lines closer than 6 of the frame's own pixels are drawn on whole pixel rows (the period rounded, 2 at least), so they never beat into moiré bands; lines finer than 2 pixels can't be drawn, and fade into an even darkening that keeps the light the lines would average to. Unit: px at 1080p."
        }
      }
    },
    "letterbox": {
      "type": "number",
      "minimum": 0,
      "maximum": 100,
      "default": 0,
      "description": "Pure black cinema bars at the top and bottom, added last by default: 0 is off and 100 crops the picture to 2.76:1. In between, the bars' height is in proportion to the amount, so on a 16:9 frame 11 gives 1.85:1, 31 gives 2:1 and 72 gives 2.39:1. A frame already 2.76:1 or wider gets no bars."
    },
    "dither": {
      "type": "object",
      "additionalProperties": false,
      "description": "The picture reduced to a few tones per channel, or to a few greys (black and white at two levels), with the in-between tones made of a pattern of dots (ordered, a Bayer matrix) or of noise, as old screens and printers did.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How much of the dithered picture shows over the picture as it was; 0 turns dithering off and 100 shows only the dithered tones. By default it applies after the grain and before letterbox bars, on the sRGB-encoded picture."
        },
        "style": {
          "type": "integer",
          "enum": [
            0,
            1
          ],
          "minimum": 0,
          "maximum": 1,
          "default": 0,
          "description": "How the in-between tones are made: 0 ordered, the regular crosshatch of an 8x8 Bayer matrix, as on old computer screens; 1 noise, an even scatter of dots that changes from frame to frame like grain (the same frame always the same), so a paused frame holds still and the export matches the preview."
        },
        "colour": {
          "type": "integer",
          "enum": [
            0,
            1
          ],
          "minimum": 0,
          "maximum": 1,
          "default": 0,
          "description": "0 dithers the picture's brightness to grey tones (black and white at two levels); 1 dithers red, green and blue each, to a few colours."
        },
        "levels": {
          "type": "integer",
          "minimum": 2,
          "maximum": 16,
          "default": 2,
          "description": "Tones per channel, from 2 (black and white, or eight colours) to 16; the tones between them are made of dots, so the picture keeps its overall light."
        },
        "size": {
          "type": "number",
          "minimum": 1,
          "maximum": 16,
          "default": 2,
          "description": "The size of the dither's dots in pixels on a 1080-line frame, scaled with the resolution and rounded to whole pixels (one at least), so each dot holds one tone and the picture keeps its light at any size: 1 is as fine as a 1080p frame's pixels, larger values give a coarser, retro pattern. Unit: px at 1080p."
        }
      }
    },
    "pixelate": {
      "type": "number",
      "minimum": 0,
      "maximum": 120,
      "default": 0,
      "description": "Big square pixels, each the average colour of the picture it covers, on a grid centred on the frame: their size in pixels on a 1080-line frame, scaled with the resolution; 0 turns pixelation off. Unit: px at 1080p."
    },
    "pattern": {
      "type": "object",
      "additionalProperties": false,
      "description": "The picture sampled on a grid and drawn as a pattern, one mark per cell: dots that grow with the light between a smallest and a largest size, or tiles of the cell's colour; over the picture or on black.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How strongly the pattern is drawn over the picture; 0 turns it off. Tiles at 100 replace the picture; dots are drawn over it (or, with a dark background, in its place)."
        },
        "style": {
          "type": "integer",
          "enum": [
            0,
            1
          ],
          "minimum": 0,
          "maximum": 1,
          "default": 0,
          "description": "The mark drawn in each cell: 0 dots, light dots that grow with the brightness of their cell, as a halftone; 1 tiles, each cell filled with its average colour between dark grid lines. (ASCII characters are an effect of their own, ascii.)"
        },
        "size": {
          "type": "number",
          "minimum": 4,
          "maximum": 80,
          "default": 16,
          "description": "The grid's cell size in pixels on a 1080-line frame, scaled with the resolution, on a grid centred on the frame. Unit: px at 1080p."
        },
        "minSize": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "The dots' smallest size, as a share of their cell's width: the size they have in the darkest cells. At 0 the darkest cells have no dot; above 0 every cell has one. Tiles fill their cells whatever this is. Unit: %."
        },
        "maxSize": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 100,
          "description": "The dots' largest size, as a share of their cell's width: the size they have in the brightest cells; at 100 they touch the dots beside them. A max below the min counts as the min. Tiles fill their cells whatever this is. Unit: %."
        },
        "background": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How far the picture behind the pattern darkens, at the pattern's strength: 0 keeps it as it is, the pattern over it; 100, with the amount at 100, leaves only the pattern, on black, hiding the picture (tiles at full strength cover it already)."
        }
      }
    },
    "ascii": {
      "type": "object",
      "additionalProperties": false,
      "description": "The picture drawn in text characters, one per cell of a grid: 0s and 1s at random over the brighter cells, or, chosen by the cell's brightness, a ramp of characters from a dot to an @ or shaded blocks; in white or in the picture's colours, over the picture or on black.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How strongly the characters are drawn; 0 turns ASCII off. At 100 they are drawn at full strength over the picture (or, with a dark background, in its place)."
        },
        "characters": {
          "type": "integer",
          "enum": [
            0,
            1,
            2
          ],
          "minimum": 0,
          "maximum": 2,
          "default": 0,
          "description": "Which characters draw the picture, in a 5x7 pixel font: 0 binary, 0s and 1s at random over most of the brighter cells; 1 classic, a ramp from a dot through - : + = % * # to @ as the cell brightens (none with less ink than the one before), as in ASCII art; 2 blocks, shades from light to solid."
        },
        "size": {
          "type": "number",
          "minimum": 6,
          "maximum": 64,
          "default": 16,
          "description": "The size of each character's cell in pixels on a 1080-line frame, scaled with the resolution, on a grid centred on the frame. Unit: px at 1080p."
        },
        "colour": {
          "type": "integer",
          "enum": [
            0,
            1
          ],
          "minimum": 0,
          "maximum": 1,
          "default": 0,
          "description": "0 draws the characters in white, brighter in brighter cells; 1 in the colour of the picture under each cell."
        },
        "background": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How far the picture behind the characters darkens, at their strength: 0 keeps it as it is, the characters over it; 100, with the amount at 100, leaves only the characters, on black, as ASCII art."
        }
      }
    },
    "glitch": {
      "type": "object",
      "additionalProperties": false,
      "description": "A digital glitch: bands of the picture torn sideways with their colours knocked apart, and blocks of it broken up, changing from moment to moment.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How often and how hard the picture glitches: bands torn sideways with their red and blue knocked apart, and blocks broken up and shifted, in bursts; 0 turns the glitch off."
        },
        "size": {
          "type": "number",
          "minimum": 2,
          "maximum": 120,
          "default": 24,
          "description": "Height of a glitched band in pixels on a 1080-line frame, scaled with the resolution; the broken blocks are a few bands across. Unit: px at 1080p."
        },
        "speed": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 50,
          "description": "How often the glitch changes, from about twice to thirty times a second; 0 holds one glitch still. It moves with the video's own time, so a paused frame holds still and the export matches the preview."
        }
      }
    },
    "grain": {
      "type": "object",
      "additionalProperties": false,
      "description": "Film-like grain, strongest in the midtones, seeded by the frame.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "Grain strength, strongest in the midtones; 0 turns grain off."
        },
        "size": {
          "type": "number",
          "minimum": 0.5,
          "maximum": 3,
          "default": 1,
          "description": "Grain size in pixels on a 1080-line frame, scaled with the resolution, so a 4K export has the same grain as a small preview. Unit: px at 1080p."
        },
        "seed": {
          "type": "integer",
          "minimum": 0,
          "maximum": 2147483647,
          "default": 0,
          "description": "Random seed: change it for a different grain pattern with the same look. The same seed and frame always give the same grain."
        }
      }
    },
    "transform": {
      "type": "object",
      "additionalProperties": false,
      "description": "Where the picture sits in the frame: moved, scaled and turned about its centre, black where it leaves the frame.",
      "properties": {
        "x": {
          "type": "number",
          "minimum": -100,
          "maximum": 100,
          "default": 0,
          "description": "Moves the picture right (positive) or left, by this share of the frame's width: at 100 it has moved a whole frame width and is out of sight. What it uncovers is black. Unit: %."
        },
        "y": {
          "type": "number",
          "minimum": -100,
          "maximum": 100,
          "default": 0,
          "description": "Moves the picture up (positive) or down, by this share of the frame's height: at 100 it has moved a whole frame height and is out of sight. What it uncovers is black. Unit: %."
        },
        "scale": {
          "type": "number",
          "minimum": 10,
          "maximum": 400,
          "default": 100,
          "description": "The picture's size, a share of the frame's, about its centre: under 100 it shrinks with black round it, over 100 it is enlarged and cropped to the frame. Unit: %."
        },
        "rotation": {
          "type": "number",
          "minimum": -180,
          "maximum": 180,
          "default": 0,
          "description": "Turns the picture about its centre, clockwise for positive angles, as Premiere and CapCut turn a clip; the corners it uncovers are black. By default the transform applies before every other effect, so the vignette, the letterbox and the rest stay where the frame has them. Unit: degrees."
        }
      }
    },
    "opacity": {
      "type": "number",
      "minimum": 0,
      "maximum": 100,
      "default": 100,
      "description": "How much of the picture shows over black, as a clip's opacity over an empty background in an editor: 100 is all of it, 0 none. Exports have no transparency, so what shows through is black. Applied last by default, after the letterbox, on the sRGB-encoded values, as editors blend. Unit: %."
    },
    "chromaKey": {
      "type": "object",
      "additionalProperties": false,
      "description": "A colour taken out of the picture to black, as a green or blue screen is keyed, with its spill taken out of what stays.",
      "properties": {
        "amount": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 0,
          "description": "How fully the key colour is taken out: 0 turns the chroma key off, 100 takes it out to black wherever it matches. Exports have no transparency, so what is keyed out is black. It keys the picture as read, after the other effects that change where it is read or filter it, and before Light and colour, so the grade and the glows work on what stays."
        },
        "hue": {
          "type": "number",
          "minimum": 0,
          "maximum": 360,
          "default": 120,
          "description": "The colour taken out, as an angle on the colour wheel: 0 red, 60 yellow, 120 green, 180 cyan, 240 blue and 300 magenta (360 is red again). A green screen is about 120 to 140, a blue one about 220 to 240. Unit: degrees."
        },
        "tolerance": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 65,
          "description": "How far a colour may be from the key colour and still be taken out: higher takes out paler and duller shades of it too, a screen's creases and shadows, lower only colours as vivid as the key's. Colours far round the wheel from it, yellow or cyan for a green key, and greys never are. Unit: %."
        },
        "softness": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 20,
          "description": "How gradually the parts taken out fade to black at their edges, so hair and motion blur keep a soft edge rather than a hard cut. Unit: %."
        },
        "spill": {
          "type": "number",
          "minimum": 0,
          "maximum": 100,
          "default": 50,
          "description": "How much of the key colour is taken out of what stays, the green or blue a screen casts on hair and edges, its light with it, as a keyer's despill does: 0 leaves it, 100 takes out all of it, so a soft edge goes dark rather than green. Only colours near the key colour change. Unit: %."
        },
        "matte": {
          "type": "integer",
          "enum": [
            0,
            1
          ],
          "minimum": 0,
          "maximum": 1,
          "default": 0,
          "description": "0 the picture, keyed; 1 the matte: white where the picture stays, black where it is taken out and grey at soft edges, to check the key, or to export as a mask for another editor."
        }
      }
    },
    "clips": {
      "type": "array",
      "description": "A timeline's looks, clip by clip in its order, as the studio's Export JSON writes them for two clips or more: applied to the studio, each clip takes the look at its place (read over its own), and clips past the list keep theirs; without a timeline the top-level look applies, the one the file was written from. The command line reads the top-level look only.",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": [
          "settings"
        ],
        "properties": {
          "settings": {
            "type": "object",
            "description": "The clip's look, a settings object as at the top level."
          }
        }
      }
    },
    "order": {
      "type": "array",
      "items": {
        "enum": [
          "transform",
          "lensDistortion",
          "rgbSplit",
          "chromaticAberration",
          "blur",
          "sharpen",
          "rain",
          "pixelate",
          "pattern",
          "ascii",
          "glitch",
          "chromaKey",
          "light",
          "bloom",
          "halation",
          "vignette",
          "fade",
          "blackWhite",
          "colourWheels",
          "lut",
          "scanlines",
          "grain",
          "dither",
          "letterbox",
          "opacity"
        ]
      },
      "uniqueItems": true,
      "default": [
        "transform",
        "lensDistortion",
        "rgbSplit",
        "chromaticAberration",
        "blur",
        "sharpen",
        "rain",
        "pixelate",
        "pattern",
        "ascii",
        "glitch",
        "chromaKey",
        "light",
        "bloom",
        "halation",
        "vignette",
        "fade",
        "blackWhite",
        "colourWheels",
        "lut",
        "scanlines",
        "grain",
        "dither",
        "letterbox",
        "opacity"
      ],
      "description": "The effects' processing order, first applied first (the studio's cards, top to bottom), by effect id. The effects a partial list names take, in its order, the places they have in the default order (this default), and the others go to their default places: a partial list is read against the default order, not the look's current one. Effects next to each other that change where the picture is read or filter it (transform, then lensDistortion, rgbSplit, chromaticAberration, blur, sharpen, then rain, pixelate, pattern, ascii, glitch, chromaKey) work together in one read of it while they come in that order (the lens, channel, blur and sharpening effects in any order among themselves, but sharpen before blur still sharpening first); bloom and halation next to each other glow from the same light. Presets and reset put the default order back."
    }
  }
}
```

### `video_effects_open_url`: Open a video or image from a URL

Download a video (MP4, MOV, M4V, WebM, MKV) or image (JPEG, PNG, WebP, AVIF) into the studio from an https URL whose server allows cross-origin requests (CORS), or from this site. The built-in examples use public Vercel Blob video URLs and a local photo (https://mjjwpgsq19yxqkr1.public.blob.vercel-storage.com/video-effects/garden-muse-v1.mp4, https://mjjwpgsq19yxqkr1.public.blob.vercel-storage.com/video-effects/scarlet-poppies-v1.mp4, https://mjjwpgsq19yxqkr1.public.blob.vercel-storage.com/video-effects/golden-coast-v1.mp4, /examples/neon-street-still-v1.jpg). The browser downloads it itself, without credentials; nothing is uploaded. To use a file on the user's device, ask the user to choose it with the Open button (once a file is open, its card, which replaces it) or drop it on the page instead.

- Returns: { ok, media, warnings } (warnings: the file's notes, such as HDR shown as standard range, a size or length that may be hard to export, or tracks left out, and any input keys ignored) or { ok: false, error: { code, message } }
- Annotations: `untrustedContentHint`
- Input schema:

```json
{
  "type": "object",
  "additionalProperties": false,
  "required": [
    "url"
  ],
  "properties": {
    "url": {
      "type": "string",
      "description": "An https URL with CORS (such as the example https://mjjwpgsq19yxqkr1.public.blob.vercel-storage.com/video-effects/garden-muse-v1.mp4), a path or URL on this site, or a data: URL."
    },
    "name": {
      "type": "string",
      "maxLength": 200,
      "description": "File name to show and to base the export's name on; defaults to the URL's last path segment."
    }
  }
}
```

### `video_effects_export`: Export the result

Render the open file with the current settings at its full resolution (smaller only where this device's graphics card can't draw it, which the result's warnings then say) and save it to the user's downloads folder. Videos: MP4 (H.264) or WebM (VP9), keeping the original audio where the browser can (where the browser can't encode H.264 or VP9 at the frame's size, the export is HEVC (H.265) or VP8 instead, and the result's warnings say so); images: PNG, JPEG or WebP. A video takes about as long as it plays, or longer; the call resolves when the file is saved. The file itself is not returned.

- Returns: { ok, result: ExportResult } (file name, size, dimensions, what happened to the audio, elapsed time)
- Annotations: `untrustedContentHint`
- Input schema:

```json
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "format": {
      "enum": [
        "auto",
        "mp4",
        "webm",
        "png",
        "jpeg",
        "webp"
      ],
      "default": "auto",
      "description": "auto: a video exports as MP4, but as WebM from WebM and from MKV holding VP8, VP9 or AV1; an image keeps its own format where the browser can write it, else PNG."
    },
    "quality": {
      "type": "number",
      "minimum": 0.5,
      "maximum": 1,
      "default": 0.92,
      "description": "JPEG and WebP quality."
    },
    "filename": {
      "type": "string",
      "maxLength": 200,
      "description": "File name without a path; the extension is set to match the format."
    }
  }
}
```

### `video_effects_cancel_export`: Cancel the export

Stop the export in progress. The open file and the settings stay as they were.

- Returns: { ok, state }
- Input schema:

```json
{
  "type": "object",
  "properties": {},
  "additionalProperties": false
}
```

### `video_effects_load_lut`: Load a LUT

Load a creative LUT (a .cube file, as DaVinci Resolve, Premiere Pro, Photoshop and LUT packs write it) and apply it to the open file, in the preview and every export, from either a URL (https with CORS, this site, or a data: URL; the browser downloads it itself, without credentials) or the file's text. The LUT is applied to the graded image, by default after the light, colour, glow, vignette, fade, black and white and the colour wheels, and before scanlines, grain, the Dither effect and letterbox (the settings' order can move it), at the strength the setting lut.amount sets (100 by default). It replaces any LUT already loaded and stays loaded when another file is opened; a file that can't be read leaves the current LUT in place and says what is wrong and on which line.

- Returns: { ok, lut: LutInfo, warnings } or { ok: false, error: { code, message } }
- Annotations: `untrustedContentHint`
- Input schema:

```json
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "url": {
      "type": "string",
      "description": "Where to download the .cube file: an https URL with CORS, a path or URL on this site, or a data: URL. Give url or text, not both."
    },
    "text": {
      "type": "string",
      "description": "The .cube file's text itself. Give url or text, not both."
    },
    "name": {
      "type": "string",
      "maxLength": 200,
      "description": "The file name to show when the LUT has no TITLE; defaults to the URL's last path segment."
    }
  }
}
```

### `video_effects_clear_lut`: Remove the LUT

Remove the loaded LUT, so the image shows without it. The settings, the LUT strength among them, stay as they were.

- Returns: { ok }
- Input schema:

```json
{
  "type": "object",
  "properties": {},
  "additionalProperties": false
}
```

## 6. Command line: `video-effects`

A command-line version of this tool for terminals, scripts and coding agents (Claude Code, Codex, Gemini CLI). It runs this page's own engine (the same settings normaliser, renderer and export) in headless Google Chrome on the user's machine, so its files are the files this page makes with the same settings, pixel for pixel. Nothing is uploaded: the CLI serves the engine from 127.0.0.1 itself, the page may reach no other origin, and the browser resolves no host name. It needs Node.js 20 or later and Google Chrome or Microsoft Edge (Chromium works too, without MP4 where its build lacks H.264). It lives in this site's source repository, in `packages/video-effects-cli/`, which is private for now, and is not published as a package yet.

An agent's loop: probe the file, render the candidate looks on one frame and look at them, then export.

```sh
video-effects probe clip.mov --json
video-effects frame clip.mov --time 3 --compare warm-film,night-film,faded --sheet -o looks.png --json
video-effects frame clip.mov --time 3 --preset warm-film --set grain.amount=30 --max-side 1280 -o check.png --json
video-effects apply clip.mov --preset warm-film --set grain.amount=30 -o clip-graded.mp4 --json
video-effects apply photos/ -o graded/ --format jpeg,webp --link "https://video-effects.jingfu.design/editor?preset=warm-film&grain.amount=30&grain.seed=7"
```

| Command | What it does |
| --- | --- |
| `apply <input\|folder...>` | Renders videos or photos with the look at full resolution and saves them, as Export does here: MP4 or WebM with the sound kept where the browser can, PNG, JPEG or WebP (`--format jpeg,webp` writes both). An MP4 is H.264, or HEVC where the browser's H.264 encoder can't take the size; each result's `videoCodec` says which. A folder stands for the files in it. `-o file` (its extension sets the format) or `-o dir/`; by default `<name>-effects.<ext>` beside each input, with `-2`, `-3` … where two inputs would share a name. Checks every input and output name before anything runs, and never replaces a file without `--overwrite` (`--skip-existing` leaves existing outputs and does the rest). |
| `frame <input>` | What `apply` would export, as an image: the same decoded frame, size, grain and LUT, so it matches the exported frame before encoding. `--time 2.5` (or `2.5s`, `1:02.5`; several with commas), `--compare <presets>` for the same frame in several looks, `--sheet` for one labelled contact sheet of them, `--split 0.5` for a before/after compare, `--max-side <px>` for a smaller image. |
| `probe <input...>` | The file's details (size, length, frame rate, codecs, rotation, colour, sound), the notes this page shows for it, the formats this machine can export it as, and the export's size and estimated bytes with the look. |
| `presets` | The presets, with a link to each. |
| `settings` | Every setting with its range, default, unit and description; `--schema` prints the settings JSON Schema; with look options, the resulting settings JSON and its link. |
| `doctor` | The browser it found: GPU, WebGL 2, WebCodecs, the H.264, VP9, AAC and Opus encoders, the formats it writes. |

Look options take the settings of sections 1 and 2: `--preset <id>`; `--link <url>`, a link with settings from this page's Copy link, so a look tuned on this page can be applied on the command line; `--settings <file|->`, settings JSON such as Copy settings writes; `--set <path=value>`, repeatable, with the dotted paths of the effects table (the effects' `order` comes through `--link` or `--settings`); `--lut <file.cube>` and `--lut-strength <0-100>` (the setting `lut.amount`). They apply in that order: the link or the preset, the JSON, then each value. Out-of-range values are clamped and reported, as here. Anything this page would quietly skip (an unknown setting, link parameter or preset, a value that isn't a number, a link with no settings) is refused before anything runs, whichever option it came through, as is an unreadable LUT; `--lenient` reads links and JSON as this page does instead.

With `--json` the CLI prints one JSON document on stdout, `{ ok, command, settings, settingsUrl, warnings, results }` for `apply`, where each result has the output path, its size (and the estimate it had), dimensions, duration and codecs (`videoCodec`, `audioCodec`, read from the file), what happened to the sound (`audio`, `audioNote`), `warnings` (something was changed or ignored) and `notes` (information only); a failure is `{ ok: false, error: { code, message, exitCode } }`. Progress, warnings and notes go to stderr; `--progress jsonl` makes each stderr line a JSON object. The exit status is 0 on success, else the error code's own (the codes under Errors below, plus the CLI's own `file_not_found`, `output_exists`, `browser_not_found`, `lut_unsupported` (a bundle built without LUT support: rebuild it) and `internal`; `video-effects --help` lists every status). Uninterrupted, a batch exits with its first failed input's status. Ctrl-C (or SIGTERM, SIGHUP) cancels the running export, leaving nothing of it behind (outputs already finished stay), and exits with 130 even when an earlier input failed: the document's top-level `error` is then `cancelled`, and `results` still has every input's own result, the inputs it stopped or never started as `cancelled`.

## 7. MCP: `https://video-effects.jingfu.design/mcp`

An MCP server for agents on the user's computer (Claude Code, Codex, Cursor, Claude Desktop, any client that speaks MCP over HTTP): give the agent `https://video-effects.jingfu.design/mcp` and it drives the editor the user has open in their browser. It speaks MCP's Streamable HTTP transport (protocol versions 2025-03-26 to 2025-11-25), stateless, every answer plain JSON, and offers the same eight tools as section 5, with the same inputs and answers.

Claude Code:

```sh
claude mcp add --transport http video-effects https://video-effects.jingfu.design/mcp
```

Codex:

```sh
codex mcp add video-effects --url https://video-effects.jingfu.design/mcp
```

Cursor, in `~/.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "video-effects": {
      "url": "https://video-effects.jingfu.design/mcp"
    }
  }
}
```

In Claude Code, the user then signs in from the `/mcp` menu (choosing `video-effects`); Codex opens the sign-in in the browser itself, and `codex mcp login video-effects` signs in again later; Cursor signs in from the MCP list in its settings. In the Claude app, it is a custom connector (Settings, Connectors, Add custom connector) with this address; any other client adds it as a remote (Streamable HTTP) server. The account page (`/account`) has a tab for each, with what to run or paste and a copy button.

- **Signing in.** The endpoint needs the user's Video Effects account (OAuth 2.1). A request without a token is answered 401 with `WWW-Authenticate: Bearer resource_metadata="https://video-effects.jingfu.design/.well-known/oauth-protected-resource/mcp"`, which is how an MCP client starts its sign-in: it registers itself (dynamic client registration), opens the user's browser at this site's account page to sign in (Google, or an email and password) and at a page asking whether to let it use the editor, and gets an access token for an hour (3600 s) with a refresh token for more. The scope is `editor`. The user sees and disconnects their agents on the account page (`/account`); a disconnected agent's token is refused at once, and so is every agent's after a password reset.
- **The editor has to be open.** The tools run in the editor (`https://video-effects.jingfu.design/editor`) open in a browser where the user is signed in to the same account: each call goes there through this site and answers once the editor has done it, as its own buttons would. With no editor open a call answers `{ ok: false, error: { code: "no_editor" } }`: ask the user to open it, then call again. Of several editors open, the one the user last had in front takes the calls. While an agent is connected a dot on the editor's account button says so, and the account dialog pauses the agent in that tab (`ve-agent-toggle`): its calls then go to another editor, or answer `no_editor`.
- **Files stay on the user's device.** No call carries a file, and no answer a file's bytes or a file name: `media.name`, the LUT's `name` and `fileName`, and an export's `filename` are left out of every answer. To use a file on the user's device, ask the user to open it in the editor; `video_effects_open_url` opens a video or image from an https link whose server allows CORS, downloaded by the user's browser, and `video_effects_load_lut` a LUT from a link or its text. An export downloads in the user's browser; the agent hears its format, size, dimensions and duration.
- **Long calls.** A call waits up to 30 s for the editor (4 min for `video_effects_open_url`, 2 min for `video_effects_load_lut`, 4.5 min for `video_effects_export`). One the editor is still running answers `{ ok: false, error: { code: "still_working" } }`: the editor carries on, and `video_effects_get_status` follows it (an export's `export` progress, then `lastExport`). `video_effects_cancel_export` stops an export.
- **Limits.** 120 tool calls a minute for an account's agents together; past that a call answers `rate_limited` with how long to wait.

## Errors

| Code | Meaning |
| --- | --- |
| `unsupported_browser` | This browser lacks WebGL 2 (or, for video export, WebCodecs). Use Chrome or Edge 111 or later, Firefox 130 or later on a desktop, or Safari 26 or later. |
| `unsupported_file` | The file is not a video or image this tool reads (MP4, MOV, M4V, WebM, MKV; JPEG, PNG, WebP, AVIF). |
| `unsupported_codec` | The browser can't decode this file's video codec (for example HEVC in Firefox, or ProRes anywhere). Convert it to H.264 or try another browser. |
| `export_unsupported` | The browser can't encode the chosen format at this size. Try the other video format, or another browser. |
| `decode_failed` | Decoding failed part-way; the file may be damaged. |
| `encode_failed` | Encoding failed part-way. Try again, or try the other format. |
| `too_large_for_gpu` | The image or frame is larger than this device's graphics card can process. |
| `out_of_memory` | The device ran out of memory. Close other tabs, or use a shorter or smaller file. |
| `gpu_lost` | The graphics context was lost (the GPU was reset or reclaimed). The preview comes back by itself within seconds; run a failed export again, and reload the page if the preview doesn't come back. |
| `fetch_failed` | The URL could not be downloaded. |
| `cors_blocked` | The URL's server does not allow cross-origin downloads (CORS), so the browser can't read it. Download the file and open it instead. |
| `no_media` | Open a video or image first. |
| `busy` | An export is running, or a file is still opening. Wait for it to finish, or cancel the export, first. |
| `cancelled` | The export or download was cancelled (cancelExport(), an aborted tool call, or a closed save dialog); the open file and the settings stay as they were. An open() is cancelled when another file is opened before it finishes, and a loadLut() when another LUT is loaded, or the LUT removed, before it finishes. |
| `invalid_input` | The input is not valid; see the warnings. |
| `invalid_lut` | The LUT can't be read. Only .cube files are (Adobe's and DaVinci Resolve's format: one 3D table of 2 to 129 points per side, or one 1D table of 2 to 65,536 entries, in a file of at most 128 MB), and the message says what is wrong and on which line. The LUT in use, if any, stays. |
| `unsupported_version` | The settings use a version this tool does not read. |

## Keyboard shortcuts

The shortcuts act only while the keyboard focus is inside the studio, after a click in it or a Tab into it, or while nothing on the page has focus and the last click was in the studio. Elsewhere on the page keys do what they normally do (Space scrolls). They are ignored while typing in a field, inside a menu, or with Cmd, Ctrl or Alt held. Scripts don't need them: `window.videoEffects` (section 4) does the same (except C, W and F, which only change the view). The studio also has undo and redo for people (Cmd+Z and Shift+Cmd+Z on a Mac; Ctrl+Z, and Ctrl+Shift+Z or Ctrl+Y, elsewhere), anywhere on the page but while typing in a field: they step through every change to the looks, the LUT and the timeline, those a script or tool made included, and start again when a file is opened. They aren't part of `window.videoEffects` or the WebMCP tools.

| Key | Action |
| --- | --- |
| Space | Play or pause |
| C | Show or hide the original (compare split) |
| O | Open a file |
| W | Show or hide the waveform |
| F | Fit the whole frame in the preview, or fill the preview with it |
| ← → | Step back or forward one frame (paused) |

## Interface languages

The studio's interface comes in English, Chinese (Simplified), Japanese and Spanish. The page's HTML, this guide, llms.txt, the JSON-LD, the settings schema and everything `window.videoEffects`, the WebMCP tools and the MCP endpoint return (statuses, notes, error messages, preset names) are English whatever the language on screen.

- The language on screen is `?lang=` in the page's URL (`en`, `zh`, `ja` or `es`), else the visitor's own choice (the language menu at the end of the title row, `[data-testid="ve-language"]`, remembered in the browser), else the first of the browser's languages that is one of these, else English. `?lang=` sits beside the settings parameters: `/editor?lang=ja&preset=warm-film`.
- Under automation (`navigator.webdriver`: Playwright, Puppeteer, WebDriver) the interface is English unless the page's URL has `?lang=`, so a script written against the English labels keeps working. A language the script picks from the language menu applies too, but only until the page is next loaded in full (a reload included): what the pick stored in the browser doesn't count under automation. A `?lang=` in the URL comes first, and a pick rewrites one already there to match.
- Test ids and every `data-` attribute are the same in every language. The labels the DOM contract names ("Open a video or image", "Export", "Cancel export", "Export format: <format>", "Add effect", "<effect name>" (a card's switch), "Reset <effect name>", "Remove <effect name>", "Replace <file name>", the time slider's "Time") are the English interface's: with another language on screen, select by `data-testid`.
- `<html lang>` follows the language on screen.

## Files, formats and browsers

### Opening files

| Type | Formats | Notes |
| --- | --- | --- |
| Video | MP4, MOV, M4V, WebM, MKV | Any codec your browser can decode: H.264, HEVC (Safari; Chrome and Edge where the device has an HEVC decoder), VP8, VP9, AV1. Phone videos are turned upright. |
| Photo | JPEG, PNG, WebP, AVIF | Turned upright following the photo's orientation tag. Transparency isn't kept: transparent areas become black. |
| HDR video | HLG, PQ, BT.2020 | Opens with a warning and is converted to standard range (SDR); the brightest highlights may clip. Footage that looks like camera Log opens with a different warning and is not converted, so it stays flat until you grade it. |
| Not supported | ProRes | No browser can decode it. Convert it to H.264 first. |

### Exporting

| Format | For | Sound |
| --- | --- | --- |
| MP4 (H.264) | Videos; the default for MP4 and MOV, and for MKV holding H.264 or HEVC | AAC and MP3 copied unchanged. Other sound is converted to AAC, or to Opus where the browser can't encode AAC (Firefox), which some players, older QuickTime among them, play silent. Sound the browser can't convert is kept as it is where MP4 can hold it (some players can't play it), else dropped; either way the page says so. |
| WebM (VP9) | Videos; the default for WebM, and for MKV holding VP8, VP9 or AV1 | Opus and Vorbis copied unchanged; other sound converted to Opus where the browser can, else dropped, and the page says why. |
| PNG, JPEG, WebP | Photos; a photo keeps its own format where the browser can write it, else PNG | None |

Exports keep the source's full resolution, unless a photo is too large for the device: wider or taller than the graphics card can process, above the 67-megapixel canvas limit of iPhone and iPad browsers, or larger than the browser can allocate. It is then scaled down to fit, and the page says so. A video is re-encoded at a high quality setting. With grain on, it is encoded at a high fixed bitrate instead, since grain is noise and an encoder left to itself smooths it away: a minute of 1080p video comes to about 650 MB as MP4, or 400 MB as WebM. In Firefox, which ignores the quality setting, the Dither effect gets that bitrate too, as its dots smear at Firefox's own. Chrome and Edge keep the dots at the quality setting, and keep it too for grain under a Dither at half strength or more, whose dots the grain's bitrate smeared there, or broke the file for 3 to 5 levels, and for a whole timeline with such a clip, which is encoded as one video. An MP4 is H.264 and a WebM VP9, except where the browser can't encode those at the frame's size: it then uses HEVC (H.265) or VP8, and the result's warnings say so. Transparency isn't kept: transparent areas become black. When a video's sound is converted, dropped or kept in a form some players can't play, the page says so.

### Browsers

| Browser | Videos | Photos | Notes |
| --- | --- | --- | --- |
| Chrome and Edge 111 or later, on a computer or Android | Yes | Yes | Can write a long export straight into a file on disk. HEVC needs a hardware decoder; on Linux, AAC and H.264 encoding may be missing. |
| Safari 26 or later, on a Mac, iPhone or iPad | Yes | Yes | Safari 26 added the audio encoders and decoders needed to convert sound. Safari can't write WebP, so photos export as PNG or JPEG. On an iPhone or iPad, a photo above 67 megapixels (8192 × 8192) is scaled down to that, the most a canvas can hold there. |
| Firefox 130 or later, on a computer | Yes | Yes | Can't encode AAC: AAC and MP3 sound is copied, and other sound in an MP4 export becomes Opus. Can't decode HEVC for export, so iPhone "High Efficiency" videos need another browser. In our tests on a desktop Mac, Firefox exported MP4 faster than real time but WebM slower: with grain on, a 1080p WebM took about nine times as long as the video lasts. MP4 is the quicker choice there. |
| Firefox for Android | No | Yes | No WebCodecs, so photos only. |

The versions listed are requirements, the oldest with the features the tool needs, not a list of versions tested. Every browser needs JavaScript and WebGL 2; videos also need WebCodecs, the browser's own video decoder and encoder. Browser builds without the licensed codecs, such as the Chromium that Playwright bundles, can't read or write H.264 or AAC.

### Processing

- **Grain is seeded by the frame.** Each frame's grain comes from its own time in the video and the seed, so a paused frame holds still, playback shows the grain moving as film does, and the exported frame has the grain the preview showed.
- **Sizes follow the frame, not the screen.** Bloom and halation radii, the blur and the RGB split are measured against the frame's shorter side and the colour fringes against its diagonal; grain, sharpening and scanline sizes are set for a 1080-line frame and scaled with the resolution; and the lens distortion, vignette and letterbox follow the frame's shape. So the small preview looks like the full-size export.
- **16-bit buffers.** Where the graphics card supports it, as almost all do, every step works on 16-bit floating-point buffers, so gradients and glows don't band; otherwise it falls back to 8-bit buffers.
- **Standard range in and out.** HDR video (HLG, as iPhones record by default, or PQ) opens with a warning and is converted to standard range (SDR), so the brightest highlights may clip; wide-gamut video (BT.2020 or Display P3) is converted to sRGB colour, so the most saturated colours may. Footage that looks like camera Log gets a warning of its own: a file doesn't say which camera's Log curve it uses, or whether it is Log at all, so it is treated as standard video, with no Log conversion, and looks flat until you grade it. Exports are standard-range (SDR) video and sRGB images.

## Privacy guarantees

- Nothing is uploaded: the browser that opens the page decodes, processes and encodes the file, and no network request carries media. This site has no upload endpoint. It counts visits twice. Vercel Web Analytics sets no cookies (Vercel tells visitors apart by a hash of the request, discarded after 24 hours): each page load sends Vercel the page's path (its query, which holds a look's settings, and its hash are dropped), the page the visitor came from and what Vercel reads from the request (country, browser, device). PostHog, in its cookieless mode, sets no cookies and stores nothing in the browser (PostHog tells visitors apart by a hash of the day, the user agent, the IP address and the host, made on its servers, and keeps neither the IP address nor the user agent): each page sends it a page view, and another event as the visitor leaves the page, through this site (`/ve-relay/`, passed on to PostHog), with the page's address and title and the page the visitor came from, every address cut to its origin and path, and what the page reads from the browser (its browser, operating system, device type, language, time zone and screen size). PostHog also gets three named events, sent the same way, with the page's address and what the page reads from the browser as above (not its title) and one or two properties of their own: `preset_applied` (`preset`, the preset's id) when the visitor chooses a preset in the studio's Presets tab, `effect_added` (`effect`, the effect's id) when they pick one from the studio's Add effect menu, and `export_finished` (`kind`, `video` or `image`, and `format`, such as `mp4`) when an export they started from the studio's export panel is saved. Nothing else about what the visitor does in the studio reaches either, and nothing about a file: not its name, its contents or a look's values. Neither counts a browser under automation (`navigator.webdriver` set) or whose user agent contains `Headless`: Vercel's script sends nothing there and PostHog's isn't loaded, so an agent or a script driving this page is never counted.
- Accounts are optional (the account button, and `/account`, never indexed): Google, or an email address and a password, confirmed by an emailed link. An account holds its email address, an empty name, its password's hash (scrypt) or its Google account id, and its sessions: never a picture, Google's tokens, the network address or browser a session came from, nor anything about a file or a look. Signing in sets one httpOnly session cookie on this site, checked against the database on every request (a session ended elsewhere ends at once), and a Google sign-in a five-minute cookie that ties Google's answer to the sign-in it started; a browser that has signed in keeps `video-effects:signed-in` in localStorage, and only its pages ask the server who is signed in. Creating an account by email loads Cloudflare Turnstile's script from challenges.cloudflare.com on that form alone, to check that the visitor is a person; the confirmation and reset emails go through Resend. Rate limits keep a network address only as a keyed hash. Nothing in the studio, `window.videoEffects`, the WebMCP tools or the command line needs an account; the MCP endpoint (section 7) does, and an account then also holds the agents it let in (their names for themselves and their tokens), until it disconnects them.
- An agent connected through the MCP endpoint (section 7) drives the editor through this site: each tool call and the editor's answer are held in the site's database only until the editor has answered (ten minutes at most), then deleted. A call carries what the agent sent (settings, a link to open, a LUT's link or text) and the answer what the editor did, with every file name taken out in the editor; never a file's bytes. Nothing about the calls is logged but that one failed and why.
- To check, open your browser's developer tools, choose the Network tab, then open and export a file: no request carries it. What you will see is the page's own files (part of its code loads the first time it is needed; the first example downloads as the page opens, to play it, and another only if you choose it, which you can while no file is open or, while an example is open, from the picture on hover) and blob: entries, which are your browser reading the opened file, or the export, from its own memory: they never leave your device. As the page opens you will also see Vercel's analytics script and one request with Vercel's count of the visit, both on this site (under /_vercel/insights/, or with Vercel's Resilient Intake at a path it picks for each build), and a moment later one with PostHog's count, sent to this site (/ve-relay/e), which passes it on to PostHog, and another as you leave the page (kept in the list with Preserve log on, as the page is gone by then). When you choose a preset, add an effect or finish an export, a request to the same address within a few seconds names it: the preset, the effect, or the export's kind (video or image) and format. Each carries the page's address cut to its path, and never a file.
- A URL carries settings only, never media, a file name or a link to media. Of a LUT it carries only the strength, `lut.amount`: never the LUT, its name or where it came from.
- A remote file is opened only by an explicit call (`window.videoEffects.open(url)`, `video_effects_open_url`; for a LUT, `loadLut({ url })`, `video_effects_load_lut`): an https URL whose server allows CORS, a file on this site, or a data: or blob: URL, downloaded by the browser itself with no credentials and no referrer.
- The examples are this site's own assets: videos in public Vercel Blob storage, with the photo and posters served by this site. Their small posters load with the page; in a visitor's browser the first example also downloads as the page opens, to play it muted (not under automation, with `?autoplay=0` in the URL, or with Save-Data on); the others download only when chosen, which the studio offers while no file is open.
- Nothing returns pixels or file bytes to an agent, except `exportBlob`, to script running in the page. Its blob stays readable until the next export, the next open or the page closing (a large export is backed by a temporary file in the browser's private storage, which is deleted then): read or save it before that, for instance with `await blob.arrayBuffer()`.
- An export doesn't copy the original's metadata tags, such as a phone's recorded location or the camera's model.
- Ask the user before choosing or opening files on their behalf.

## Changelog

- 2026-10-18: version 1 (API version 1, settings version 1).
- 2026-10-21: moved to its own site, https://video-effects.jingfu.design/editor. The page is now at `/`, this guide at `/index.md`, the schema at `/settings.schema.json` and the local example assets under `/examples/` (the current videos use the Blob URLs listed above); the old paths on this site (`/video-effects`, `/video-effects.md`, `/video-effects/settings.schema.json`, `/lab/video-effects/…`) redirect to them, query included. The settings, their links' parameters and the API are unchanged, so a link made before the move carries the same look here: pasted into the page, given to the command line, or with only its host changed.
- 2026-10-21: new example clips, `garden-muse`, `scarlet-poppies` and `golden-coast` (1920×1080, 6 s, no sound), in place of `neon-street`, `seaside` and `cafe`. Those three ids are gone, and their files (`/examples/neon-street-v1.mp4`, `/examples/seaside-v1.mp4`, `/examples/cafe-v1.mp4`, also reached through the old `/lab/video-effects/…` paths) were removed: they answer 404. The photo, `neon-street-photo`, stays.
- 2026-10-21: the presets and Reset all moved to a tab of their own, Presets (`data-panel="presets"`), first in the settings panel; the tab that holds the effects, renamed Effects (still `data-panel="adjust"`), is still the one selected when the page opens, so a script that clicks a preset now selects the Presets tab first (or calls `applySettings({ preset })`). The effect cards can be dragged into another order by a handle (`ve-effect-handle`), which is the look's processing order: the settings have a new optional field, `order` (a link's `order=` parameter), within settings version 1, so every earlier link and settings file still reads, with the default order. Fade is an effect of its own (`data-effect="fade"`), apart from Light and colour, so its row (`data-setting="fade"`) is in the Fade card. The status and error lines (`ve-status`, `ve-error`) are visually hidden and no longer live regions: people see toasts. Export JSON (`ve-export-json`) downloads the look as a file, with every clip's look on a timeline (`clips`, which `applySettings` reads), and Import JSON (`ve-import-json`) applies one. While an example is open, the other example clips' `ve-example` buttons are in the page too.
- 2026-10-22: four new effects and a progressive blur, all within settings version 1 (old links and settings files read as before, with these off): `rain` (Rain on glass: `rain.amount`, `rain.size`, `rain.speed`, `rain.fog`), `pixelate` (`pixelate`, the pixel size), `pattern` (Grid pattern: `pattern.amount`, `pattern.style`, 0 dots, 1 tiles, 2 ASCII, and `pattern.size`) and `glitch` (`glitch.amount`, `glitch.size`, `glitch.speed`); the blur grows across the frame with `progressiveBlur.amount` above 0 (`progressiveBlur.start`, `progressiveBlur.angle`). The new effects come after `sharpen` in the default order, so an `order` written before them keeps its meaning. A visitor's first visit opens an introduction (`ve-welcome`), never under automation.
- 2026-10-23: ASCII and Dither, within settings version 1. ASCII is an effect of its own (`ascii`: `ascii.amount`, `ascii.characters`, 0 binary, 1 classic, 2 blocks, `ascii.size`, `ascii.colour`, 0 white, 1 the picture's, and `ascii.background`), read with the others after the grid pattern; the grid pattern's style keeps only 0 dots and 1 tiles, so `pattern.style` 2, its ASCII until now, is clamped to 1 with a warning. Dither (`dither`: `dither.amount`, `dither.style`, 0 ordered, 1 noise, `dither.colour`, 0 black and white, 1 colour, `dither.levels`, `dither.size`) is a step on the sRGB values, after grain and before the letterbox by default; in Firefox, which ignores the quality setting, a video exported with it on gets the high fixed bitrate grain does, so the estimated size grows likewise (Chrome and Edge keep its dots at the quality setting, and keep that setting for grain under a Dither at half strength or more, where the grain's bitrate broke their files for 3 to 5 levels, and for a whole timeline with such a clip). The grid pattern gains `pattern.background`, as ASCII has: how far the picture behind it darkens, so at 100 (with `pattern.amount` 100) only the pattern shows, on black.
- 2026-10-23: Black and white, within settings version 1 (`blackWhite`: `blackWhite.amount` and `blackWhite.reverse`, 0 normal, 1 reversed, a negative), a step in linear light after the fade by default; and the grid pattern's `pattern.minSize` and `pattern.maxSize`, the dots' width in the darkest and the brightest cells as a share of the cell (0 and 100 by default, the dots as they were in cells of 12 output pixels or more). Dots in smaller cells (the default `pattern.size` below 810 lines, a 720p export for one) are now sampled more finely, so small cells keep their light, and pattern looks draw a little differently at those sizes.
- 2026-10-23: the site counts visits with Vercel Web Analytics (`@vercel/analytics`): a page view per page shown (each load, and the 404 page's link home), first-party and without cookies, its address cut to the page's path (the query and hash dropped); nothing about the studio's use or a file is sent.
- 2026-10-25: the site also counts visits with PostHog (`posthog-js`, loaded at an idle moment once the page has settled): a page view per page shown and an event as the visitor leaves it, sent to this site at `/ve-relay/` and passed on to PostHog, in PostHog's cookieless mode (no cookies, nothing stored in the browser), every address cut to its origin and path; no campaign parameters, clicks, recordings or surveys, and never from a browser under automation. Vercel Web Analytics counts on as before.
- 2026-10-26: PostHog also gets three named events from the interface: `preset_applied` (`preset`) when a visitor chooses a preset in the Presets tab, `effect_added` (`effect`) when they pick one from the Add effect menu, and `export_finished` (`kind` and `format`) when an export started from the studio's export panel is saved (the effect pages' demos export nothing: their Open your own file opens the editor with the look). Nothing else about the studio's use is sent, and nothing about a file; agents' calls (`window.videoEffects`, WebMCP) send none, and a browser under automation nothing at all.
- 2026-10-27: an MCP server at `/mcp` (section 7): an agent on the user's computer signs in through the user's account (OAuth 2.1) and drives the editor they have open with the WebMCP tools of section 5, relayed through this site. The DOM contract gains `ve-agent-toggle` and `ve-agent-client`, and the account button `data-agent`.
- 2026-10-26: Colour wheels, within settings version 1 (old links and settings files read as before, the wheels at neutral): an effect of its own, `colourWheels`, in Light and colour, with three settings objects, `shadows`, `midtones` and `highlights`, each a wheel with `hue` (degrees: 0 red, 60 yellow, 120 green, 180 cyan, 240 blue, 300 magenta), `amount` (how far its tones are pushed toward the hue, 0 to 100) and `brightness` (-100 to 100). They are a grading app's lift, gamma and gain, applied to the sRGB values after black and white and before the LUT by default. In the studio the card shows three wheels (`ve-colour-wheel`, `data-wheel`), whose puck sets the hue and colour, each over a brightness row (`ve-control`).
- 2026-10-26: the editor moved to `/editor`, and the site's root, `/`, is its home page: what the tool is, a live demo of the presets, the effect pages and the FAQ. Settings links, Copy link, `getSettingsUrl()`, the WebMCP tools and the command line now give `/editor?…`. A link to the root with a setting, `preset`, `order`, `v` or `autoplay` in its query is redirected to the editor (308), its query kept, so every link made before works as it did; so is `/video-effects`, the tool's first address, now to `/editor`. Scripts that open the root and wait for `data-state="empty"` should open `/editor` (a bare `/` is the home page, which has no studio). The welcome a visitor sees on a first visit to the editor now rotates through four slides; `ve-welcome-start` is its Skip button.
- 2026-10-26: optional accounts: sign in with Google, or create one with an email address and a password (confirmed by an emailed link, the sign-up checked by Cloudflare Turnstile), from the account button (`ve-account`) before the language menu, or at `/account`. An account holds only its email address and does nothing yet: the studio, its API, its tools and the command line work as before without one.
- 2026-10-26: the timeline has a cover, as CapCut does (`ve-cover` at the front of the track, its menu `ve-cover-item`, `ve-cover-input`): a frame of the video or an image, which video exports carry as their cover art (MP4's `covr`, a WebM attachment). None by default; a script exporting through `window.videoEffects` gets the cover only where the studio set one.
- 2026-10-26: the preview has a context menu of its own (`ve-frame-menu`, its items `ve-frame-item` with `data-action`): Save frame as PNG and Copy frame, the frame on show at full size with the look on show (never the compare split), and the view's toggles. The Add effect menu is a grid of tiles under the groups' names (still role `menu`, its tiles `ve-effect-item`), and the first visit's introduction is a tour of four steps, the first two lighting the part of the editor they are about (`ve-welcome`, with `data-step`). For people: nothing a script relies on changed.
- 2026-10-27: the browser keeps its choices under `video-effects:` keys: the language in `video-effects:lang` and the welcome tour's "seen" in `video-effects:welcome`, which a script that overrides `navigator.webdriver` sets to skip the tour (the earlier keys are no longer read, so a returning visitor picks the language again and sees the tour once more). The site names no author: the JSON-LD's publisher is the tool itself (an `Organization`, `/#organization`), and titles end in " | Video Effects" where they don't start with the name.
- 2026-10-27: Transform and Opacity, within settings version 1 (old links and settings files read as before, with the picture where it was and fully shown), in a group of their own, Transform. `transform` (Transform: `transform.x` and `transform.y`, the picture moved right and up by a share of the frame's width and height, -100 to 100; `transform.scale`, 10 to 400 %; `transform.rotation`, -180 to 180 degrees clockwise) changes where the picture is read, first by default, black outside it, so every effect after it works on the frame as moved; `opacity` (0 to 100, 100 by default) shows the picture over black, last by default. Presets and reset put both back to their defaults, as they do every other effect.
- 2026-10-27: Chroma key, within settings version 1 (old links and settings files read as before, the key off), in a group of its own, Keying: `chromaKey` takes a colour out of the picture to black, a green or blue screen's, with `chromaKey.amount` (0 to 100, 0 off), `chromaKey.hue` (the colour, in degrees: 120 green, 240 blue), `chromaKey.tolerance`, `chromaKey.softness`, `chromaKey.spill` (the key colour taken out of what stays) and `chromaKey.matte` (0 the picture, 1 the matte: white where the picture stays). It keys the picture as read, last of the effects that change where it is read or filter it and before Light and colour, so the grade and the glows work on what stays. Exports have no transparency, so what is keyed out is black; export the matte too for a mask another editor can use. For people, the card's Colour row has an eyedropper that picks the colour from the picture.
