# Jitter

> Control Jitter (jitter.video) for exporting animations, replacing assets, modifying text, and rendering frames at specific times programmatically via Playwriter.

- Skill: `remorses/jitter` (Agent Skill, multi-file: 13 files)
- Install (CLI): `npx skillmds@latest add remorses/jitter`
- Raw SKILL.md: https://api.skillmd.com/api/skills/remorses/jitter/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: remorses (https://skillmd.com/u/remorses)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/remorses/jitter

---


# Jitter Programmatic Control Skill

Control Jitter (jitter.video) for exporting animations, replacing assets, and modifying text.

## Setup

Load utils before interacting with Jitter:

```javascript
// Load once per page (before navigation or via addInitScript)
await page.addInitScript({ path: './skills/jitter/dist/jitter-utils.js' })

// Navigate to project
await page.goto('https://jitter.video/file/?id=YOUR_FILE_ID')

// Wait for app to be ready
await page.evaluate(() => jitterUtils.waitForApp())
```

## API Reference

### Traversal

| Function                | Description                        |
| ----------------------- | ---------------------------------- |
| `findNodeById(id)`      | Find node by ID                    |
| `findAllMediaNodes()`   | Get all images/SVGs/videos/GIFs    |
| `findAllTextNodes()`    | Get all text nodes                 |
| `getArtboards()`        | Get all artboards with dimensions  |
| `findNodesByType(type)` | Find nodes by layer type           |
| `findNodesByName(name)` | Find nodes by name (partial match) |
| `flattenTree()`         | Get all nodes as flat array        |

### Actions

| Function                       | Description                 |
| ------------------------------ | --------------------------- |
| `replaceAssetUrl(nodeId, url)` | Replace image/SVG/video URL |
| `replaceText(nodeId, text)`    | Replace text content        |
| `updateNode(nodeId, props)`    | Update any node properties  |
| `batchReplace(replacements)`   | Batch update multiple nodes |
| `selectNodes(nodeIds)`         | Select nodes by ID          |
| `removeNodes(nodeIds)`         | Remove nodes                |
| `undo()` / `redo()`            | Undo/redo actions           |

### Export

| Function                                    | Description                      |
| ------------------------------------------- | -------------------------------- |
| `generateExportUrl(opts)`                   | Generate export URL with options |
| `generateExportUrlFromCurrentProject(opts)` | Export URL for current project   |
| `parseJitterUrl(url)`                       | Parse file/node IDs from URL     |
| `getFileMeta()`                             | Get current file metadata        |

### Snapshot & Restore

| Function                                           | Description                               |
| -------------------------------------------------- | ----------------------------------------- |
| `createSnapshot(nodeIds)`                          | Save node states                          |
| `restoreFromSnapshot(snapshot)`                    | Restore saved states                      |
| `duplicateProject()`                               | Clone current project                     |
| `withTemporaryChanges(nodeIds, changes, callback)` | Apply temp changes, run callback, restore |

### Waiting

| Function                        | Description            |
| ------------------------------- | ---------------------- |
| `waitForApp(timeout?)`          | Wait for app to load   |
| `waitForSync(delay?)`           | Wait for server sync   |
| `waitForNode(nodeId, timeout?)` | Wait for node to exist |
| `isAppReady()`                  | Check if app is ready  |

## Examples

### Replace Assets and Export

```javascript
// Get all media nodes
const media = await page.evaluate(() => jitterUtils.findAllMediaNodes())

// Replace specific assets
await page.evaluate(() => {
  jitterUtils.batchReplace([
    { nodeId: 'abc123', data: { url: 'https://example.com/new-image.svg' } },
    { nodeId: 'def456', data: { url: 'https://example.com/new-photo.jpg' } },
  ])
})

// Wait for sync then export
await page.evaluate(() => jitterUtils.waitForSync())
const exportUrl = await page.evaluate(() =>
  jitterUtils.generateExportUrlFromCurrentProject({ profile: 'lottie' }),
)
await page.goto(exportUrl)
```

### Export with Temporary Changes

```javascript
await page.evaluate(async () => {
  const nodeIds = ['node1', 'node2']
  const changes = {
    node1: { url: 'https://temp-asset.svg' },
    node2: { text: 'Temporary Text' },
  }

  await jitterUtils.withTemporaryChanges(nodeIds, changes, async () => {
    // Changes applied here, will be restored after
    const url = jitterUtils.generateExportUrlFromCurrentProject()
    // ... navigate to export URL and download
  })
  // Original values automatically restored
})
```

### Find and Update Text

```javascript
const textNodes = await page.evaluate(() => jitterUtils.findAllTextNodes())
// [{ id, name, text, fontSize, fontFamily }, ...]

await page.evaluate(() => {
  jitterUtils.replaceText('textNodeId', 'New headline')
})
```

## Export Profiles

| Profile      | Output                         |
| ------------ | ------------------------------ |
| `lottie`     | Lottie JSON (vector animation) |
| `mp4`        | H.264 video                    |
| `gif`        | Animated GIF                   |
| `webm`       | WebM video                     |
| `prores4444` | ProRes 4444 (with alpha)       |
| `pngs`       | PNG sequence                   |

## Lottie Export Limitations

- **NodeIds are NOT preserved** in exported Lottie - cannot map back to Jitter nodes
- **Text becomes shapes** - not editable Lottie text layers
- **Images are embedded** as base64, no external URLs
- **Videos** export as first frame only

**Workaround:** Always modify assets in Jitter before export using `replaceAssetUrl()`.

### Inspecting exported Lottie files

Lottie JSON files from Jitter contain **giant base64-encoded image strings** that make
them unreadable. Use `jq` with `walk()` to truncate long strings before inspecting:

```bash
# Pretty-print with long strings truncated and huge arrays summarized
jq 'walk(
  if type == "string" and length > 80 then .[:80] + "..."
  elif type == "array" and length >= 20 then .[:3] + ["\(length - 3) more items..."]
  else . end
)' exported.json

# Pipe to less for paging
jq 'walk(
  if type == "string" and length > 80 then .[:80] + "..."
  elif type == "array" and length >= 20 then .[:3] + ["\(length - 3) more items..."]
  else . end
)' exported.json | less
```

This replaces embedded base64 image data with truncated previews and collapses
large arrays (20+ items, common in Bezier path data) to just the first 3 elements
plus a count, so you can read the animation structure without scrolling through
walls of encoded pixels or coordinate lists.

## Rendering Frames (Seek + Screenshot)

Jitter has a hidden `/api/renderer/` page with a headless rendering engine. It exposes
`window.jitter.renderFrame(timeMs)` which renders a full-quality frame at any point
in the animation and returns a raw PNG. This is the same engine Jitter's own export
backend uses (server-side Puppeteer calling `renderFrame()` in a loop).

### Getting file and artboard IDs

From the editor page URL `https://jitter.video/file/?id=FILE_ID&nodeId=ARTBOARD_ID`,
or programmatically:

```javascript
const fileId = await page.evaluate(() => window.app.props.fileMeta.id)
const artboard = await page.evaluate(() => {
  const conf = window.app.getState().observableImmutableConf.getSnapshot()
  const root = conf.roots[0]
  return { id: root.id, width: root.item.width, height: root.item.height, duration: root.item.duration }
})
```

### Opening the renderer

Navigate to `/api/renderer/` with these URL params. **Use a separate page** from the
editor so you don't lose editor state.

| Param | Required | Description |
|---|---|---|
| `file` | yes | project file ID |
| `artboardId` | yes | which artboard to render |
| `width` | yes | output width in px (use artboard width for 1:1) |
| `height` | yes | output height in px (use artboard height for 1:1) |
| `bucket` | yes | `snackthis-userdata` (the default S3 bucket) |
| `superSampling` | yes | must be `2` or higher; `1` fails Zod validation |
| `noBg` | no | `true` for transparent background |
| `addWatermark` | no | `false` to skip watermark |
| `playbackDirection` | no | `normal`, `reverse`, or `boomerang` |
| `vfe` | no | video fallback export, `off` for stills |

```javascript
const rendererUrl = [
  'https://jitter.video/api/renderer/',
  `?file=${fileId}`,
  `&bucket=snackthis-userdata`,
  `&artboardId=${artboardId}`,
  `&width=${width}`,
  `&height=${height}`,
  `&superSampling=2`,
  `&noBg=false`,
  `&addWatermark=false`,
  `&playbackDirection=normal`,
  `&vfe=off`,
].join('')

state.rendererPage = context.pages().find(p => p.url() === 'about:blank') ?? await context.newPage()
await state.rendererPage.goto(rendererUrl, { waitUntil: 'domcontentloaded' })
```

### Waiting for the renderer to be ready

The page dispatches a `jitterLoadEvent` custom event when ready, or sets
`window.jitter` directly. Wait for either:

```javascript
const exportContext = await state.rendererPage.evaluate(() => {
  return new Promise((resolve, reject) => {
    const timeout = setTimeout(() => reject(new Error('Renderer timed out')), 30000)
    if (window.jitter) {
      clearTimeout(timeout)
      return resolve(window.jitter.exportContext)
    }
    document.addEventListener('jitterLoadEvent', (e) => {
      clearTimeout(timeout)
      if (e.detail.name === 'ready') resolve(e.detail)
      else if (e.detail.name === 'error') reject(new Error(e.detail.message))
    })
  })
})
// exportContext = { exportWidth, exportHeight, exportDuration }
```

### Rendering a frame at a specific time

`renderFrame(timeMs)` returns a raw binary PNG string. Convert to base64 inside
the browser, then decode to a Buffer in the sandbox:

```javascript
// Render frame at 4000ms (4 seconds into the animation)
const base64Png = await state.rendererPage.evaluate(async () => {
  const result = await window.jitter.renderFrame(4000)
  return btoa(result.pngString)
})

const fs = require('node:fs')
const buf = Buffer.from(base64Png, 'base64')
fs.writeFileSync('/tmp/frame-4000.png', buf)
```

The output is a full-resolution PNG matching the `width`/`height` params, with no
editor UI, selection handles, or chrome. Just the clean artboard content at that
exact animation frame.

**The artboard may be letterboxed inside the PNG.** With `width=1920&height=1080`
and `superSampling=2` the artboard rendered at half size (960x540) centered with
transparent padding. Before comparing against other renders, find the content
bbox once (`magick frame.png -alpha extract -threshold 50% -format "%@" info:`)
and crop + flatten onto the artboard fill color.

### Rendering multiple frames

Loop over timestamps to capture a sequence:

```javascript
const fps = 30
const durationMs = exportContext.exportDuration // e.g. 8450
const totalFrames = Math.ceil(durationMs / 1000 * fps)

for (let i = 0; i < totalFrames; i++) {
  const timeMs = Math.round(i / fps * 1000)
  const base64 = await state.rendererPage.evaluate(async (t) => {
    const result = await window.jitter.renderFrame(t)
    return btoa(result.pngString)
  }, timeMs)
  
  const buf = Buffer.from(base64, 'base64')
  fs.writeFileSync(`/tmp/frames/frame-${String(i).padStart(4, '0')}.png`, buf)
}
```

### Seeking on the editor timeline (alternative)

If you just need to visually scrub the editor timeline without the full renderer,
click on the **timeline ruler** element. The ruler spans from x=272 (0ms) to the
duration end. Clicking sets the time cursor:

```javascript
// On the editor page (not the renderer page)
const rulerY = 1120 // vertical center of the ruler bar
const rulerStartX = 272
const rulerEndX = 1962 // corresponds to total duration

const fraction = 4000 / 8450 // target time / total duration
const clickX = rulerStartX + (rulerEndX - rulerStartX) * fraction
await state.page.mouse.click(clickX, rulerY)
```

This updates the editor canvas preview but the canvas only shows the viewport-visible
portion of the artboard. For full-resolution artboard screenshots, use the
`/api/renderer/` approach above.

## Porting to Remotion / egaki

See [PORTING-TO-REMOTION.md](./PORTING-TO-REMOTION.md) for the full guide:
scene graph extraction, concept mapping table, easing usage, additive animation
model, and frame-by-frame comparison workflow.

All Jitter easings are exported from `egaki/video` as continuous-intensity
preset functions (e.g. `impulseOvershoot(96)`), generated by a port of
Jitter's own curve engine. See the easing mapping table in the porting guide.

**Quick reference** for `smooth:standard:v1`:
`cubic-bezier(lerp(0.3, 0.9, intensity/100), 0, 0, 1)`. At intensity 50:
`Easing.bezier(0.5, 0, 0, 1)`.

## Tips

1. **Wait for sync** after modifications before exporting (1-2 seconds)
2. **Asset URLs** must be publicly accessible - Jitter fetches server-side
3. All `*WithUndo` actions can be undone with Ctrl+Z
4. Node IDs are stable and bookmarkable via `?nodeId=xxx`
5. Export URLs require being logged in with project access
6. **`superSampling` must be 2 or higher** when using `/api/renderer/`; value `1` fails Zod validation
7. **Use a separate page** for the renderer so you don't lose editor state

