# AGONECO Video Editor — guide for AI agents

AGONECO Video Editor (https://tools.agoneco.com/) is a free video editor that runs in the browser. Media stay on the user's computer and there is no hosted media service, account or key. You describe a short video as a JSON project; the user sees it on a timeline, adjusts it by hand or asks you for changes, and exports MP4, transparent MOV or PNG.

Good fits: endings and intros, logo or character animations, captions over a clip, simple slideshows, motion on images. Effects are motion presets (shake, float, zoom, move, …) on image, video and text clips — not generative video.

**Everything you need is in this document.** You never need a sample link or an existing project from the user.

## Choose how to hand it over

- **You can run commands on the user's computer** (Claude Code, Codex, opencode, …) and the user has the **AGONECO desktop app** (https://tools.agoneco.com/download/): work in a project folder and drive it with the `agoneco` command the app installs. The app's editor window streams local media, saves back into the project and merges your changes without reloading. This is the way to edit together.
- **You can only chat, or the user only uses the web editor**: put the JSON in a link (`#json=`); the user opens it and connects the files it names. The web editor has no agent connection.

## A. Desktop app (edit together)

1. The user installs the app once; it adds the `agoneco` command (Settings → Command-line tool; macOS puts it in `~/.local/bin`, Windows adds it to the user PATH). It needs no Node.js or other install. `agoneco --version` prints the app version.
2. Open what the user has in the app. One project folder has one editor window; opening it again focuses that window.
   - **A project folder**: `agoneco open <folder>`. The live project is `project.agoneco` (JSON), and `media.index.json` indexes media that stays in place; nothing is copied. `project.json` is still read for folders made before this format.
   - **A portable package**: `agoneco open ending.agoneco.zip` imports an ordinary `.agoneco.zip` (or legacy `.agoneco`) into a work folder beside it (`ending/` with `project.agoneco` and `media/`). The source archive is never updated by saves; work in the folder. An explicit `.agoneco.zip` export packages referenced media when the user needs to hand it off.
   - **A new project**: make a folder with `project.agoneco` (format below) and the media it uses, referred to by relative path (`"src": "A.png"`), then `agoneco open <folder>`. The user can also start one from the app's start window (presets and templates).
   `agoneco open` starts the app when it is not running. The app writes an `AGENTS.md` with this guide into the project folder (it never replaces a file it did not write).
3. Loop with the user:
   - The live project is `project.agoneco`; `media.index.json` is a rebuildable index of on-disk media, not a second project. When you change `project.agoneco`, the open editor merges it without reimporting unchanged media (the user can undo it). Independent property edits merge automatically. On a same-property conflict the user's value stays and yours waits as a conflict card the user resolves; state lists open ones in `conflicts` and recent decisions in `resolvedConflicts` (`choice:"mine"` means they declined your value — do not propose it again unasked).
   - The editor autosaves every edit to `project.agoneco` in its full form (after a drag ends), and new media land in `media/`. **Always re-read `project.agoneco` before your next edit** so you keep the user's changes. Do not edit `media.index.json`; it is regenerated.
   - Files the user adds in the editor appear in `media/` under their own names right away (`media/logo.png`, `media/logo-2.png` if taken). You may use them in your edits.
   - Asset deletion requires the person: use `agoneco assets remove ID` to request it, then `agoneco assets deletion-status REQUEST_ID --wait`. A pending request is not deletion. `ASSET_DELETION_DENIED` means stop and keep the asset; never retry or bypass through proposals, raw file APIs or filesystem deletion.
   - Media you put in the folder yourself (a downloaded image, a rendered clip) appear in the editor's asset library within a second, and replacing a file the project uses reloads it in place. Library updates can create unsaved changes. Use `save` before taking a comparison baseline, and `refresh-media [path…]` when file identities differ.
   - Local media: playable video/audio stream regardless of size. The app uses a compatible system ffmpeg, or downloads a pinned, SHA-256-verified binary once into the user cache (macOS arm64/x64, Windows x64). Other systems can set `AGONECO_FFMPEG`. Local proxies preserve alpha and original sources. If local processing fails, the user chooses retry or browser conversion. Do not manually flatten a scene into an MP4 as a workaround.
   - State and other commands flush pending edits first; an explicit Save As/copy exports `.agoneco.zip`, which includes required media, editing copies and user fonts and opens on another computer. Use the package for handoff, not as the live project.
   - Neither side overwrites a newer file silently: a save or apply based on an older revision the app still knows is merged three ways (apply output then has `merged:true` and the conflicting `conflicts` paths); an unknown base is refused.
4. When a rendered file is requested, use the official `export` command below, or have the user export through File → Export. Never substitute a Python/Pillow/custom FFmpeg compositor. If official export is unavailable, report the blocker and leave the project open.

### Quick edits: `agoneco patch`

For targeted changes, send operations by id instead of rewriting the file: no state check, hashing, proposal file, apply and wait of your own. `patch` reads the open project's current revision, applies the operations, validates, applies (or compares) and waits until the editor shows it — one command, one JSON result with `changed`, `diskRevision` and `ready`.

```sh
agoneco patch - --no-compare <<'JSON'
[{"op":"effects","clip":"elefeed_scene","effects":[
  {"at":0,"duration":0.5,"effect":{"preset":"enter","transitionMotion":"fade","curve":"linear"}},
  {"at":0,"duration":0.7,"effect":{"preset":"move","moveAngle":270,"moveDistance":34,"curve":"easeOut"}}]},
 {"op":"set","clip":"title","value":{"placement":{"y":-220},"text":{"color":"#ffe08a"}}}]
JSON
```

| op | fields | does |
| --- | --- | --- |
| `set` | `clip` / `track` / `effect`: id, or `project`: true; `value` | Merges fields: objects merge, arrays replace, `null` deletes a field. Ids never change. |
| `effects` | `clip`, `effects` | Replaces every effect range of the clip (inline and in lanes). |
| `add` | `clip`: {…} or `track`: {…} (optional `before`/`after` track id), or `effect`: {…} with `to`: clip id | Adds it; a missing id is generated, a clip without a track gets its own. |
| `remove` | `clip` / `track` / `effect`: id, `cascade` | Removes it. A group with members or a track with clips needs `"cascade": true`. |
| `move` | `clip`, `by` or `to` (seconds), optional `track` | Shifts the clip and its effect ranges (a group with all its members). |
| `contour` | `clip`: a PNG or video clip id; optional `style` (`silhouette` filled, default · `outline` line only), `id`, `name`, `fill`, `fillOpacity`, `stroke`, `strokeOpacity`, `strokeWidth` | Adds a contour shape that follows that clip (see Contour shapes below). |

#### Contour shapes (cover or tint a PNG / transparent video)

To black out a character, put a translucent red over it or draw only its outline, make a **contour shape** that follows the source clip: `{"op":"contour","clip":"<source id>"}`. Do not draw the outline yourself, trace frames, or add a separate coloured copy of the video.

```json
[{"op":"contour","clip":"hero","id":"hero-shadow","style":"silhouette","fill":"#000000","fillOpacity":1},
 {"op":"contour","clip":"hero","id":"hero-line","style":"outline","stroke":"#ff3b30","strokeWidth":4}]
```

- The source is a PNG clip or a **transparent-background video** (mov/webm with alpha). The contour is the source's alpha: for a video it follows every frame, for a PNG the editor analyses the picture when the project opens.
- It follows the source's position, scale, rotation, flip, four-corner transform, crop and fades. **Move, transform and time the source, not the contour**: the contour has no position of its own and appears only while the source is visible (hidden or outside its range → the contour disappears too).
- `silhouette` fills the shape (`fillEnabled`); `outline` draws only a line centred on the edge (`strokeEnabled`). Colour, opacity and width are the clip's `shape` fields (`fill`, `fillOpacity`, `stroke`, `strokeOpacity`, `strokeWidth`, `fillEnabled`, `strokeEnabled`). Change them later with `{"op":"set","clip":"hero-shadow","value":{"shape":{"fill":"#ff0000","fillOpacity":0.4}}}`, or animate them with a `shapeStyle` effect range on the contour clip.
- The contour sits on a new track right in front of the source's track. Its `shape.paths` is a placeholder box and `src` a blank 1×1 PNG: never edit them, and never create a contour with `add` by hand. Blur, trail and light effects work on a contour; move/zoom/rotate belong on the source, and shadows are not drawn for it.
- For a PNG you may also pass `threshold` (1–255 alpha cut-off, default 8) and `tolerance` (0–32 px smoothing, default 1). They are ignored for video.

Choose exactly one mode: `--compare` (render before/after and stop; add `--time`/`--from --to --count`), `--comparison <id>` (apply what you compared — the same operations on the same revision), `--no-compare` (apply without a visual check: metadata, timing you will inspect with sheet), or `--dry-run` (validate here, change nothing). Operations come from a file, `-` (stdin) or inline JSON. On `PATCH_INVALID` the error names the operation index; on `VALIDATION_FAILED`/422 the details list the entities. Use whole-file edits (apply) for large restructurings.

### Folder/ZIP contract (API 5)

All normal saves and applies update only the project folder. A portable `.agoneco.zip` contains `project.agoneco` JSON, `media.index.json`, referenced media and optional `history.json`. Ordinary stored/Deflate ZIPs and a single wrapping folder are accepted. Legacy `project.json` packages remain readable. Reopening an unchanged archive reuses its working folder; a changed archive never overwrites existing edits: open the work folder, or move it aside.

Work-command stdout contains exactly one JSON object on success or failure. Paths and logs go to stderr. Apply returns `waitRevision`; a reload alone does not save again.

### See what you made — no browser control needed

**Preserve editability: never pre-render a completed scene and insert it as a single clip. Keep original images, video, audio, text and effects as independent editable elements. A per-source playback proxy is allowed; a flattened scene is not.**

Use this loop: **state → media → validate → compare → inspect the pair image → apply --comparison → wait → sheet / frame / audio → export (when requested)**. For targeted edits, `agoneco patch` does validate → compare/apply → wait in one step (above).

**Window use:** Use the `agoneco` command and official tools by default. Limit direct browser use to finding and acquiring assets such as images. If the user explicitly asks you to operate the editor window, follow that instruction within the requested scope. Do not independently substitute UI automation for the tool workflow.

Commands find the project from the current folder (the project folder, a folder inside it, or its parent when only one open project is there); `--folder <exact-work-folder>` names it exactly.

- `agoneco validate project.agoneco` (or legacy `project.json`) — offline validation of files, IDs, effect ranges and actual clip/track counts after automatic audio splitting; it works without the app. JSON result; exit 1 on failure.
- Read `agoneco state --folder <exact-work-folder>`. Confirm `projectId`, `workdir`, `projectFile` and `editor.connected` identify the project the user is editing. Never create a final version in a sibling/subfolder while leaving the open project unchanged. Take its `diskRevision`, re-read the file, and write your proposal to a **separate** JSON file. After comparing and inspecting the proposal, `agoneco apply proposal.json --project-id <state.projectId> --revision <diskRevision> --folder <state.workdir> --comparison <comparisonId>`. A stale base is refused; re-read and revise rather than force overwrite.
- `agoneco wait --revision <apply.waitRevision>` — wait for the editor to load that revision before inspection. Conflicts, sync errors and obsolete revisions fail instead of inspecting an old timeline.
- `agoneco save --folder <workdir>` flushes pending editor changes; `agoneco refresh-media [paths…] --folder <workdir>` rechecks media without discarding edits. Both return JSON or an actionable failure.
- `agoneco wait --latest --folder <workdir>` waits for the current saved head. Do not combine it with `--revision`; exact waits reject intervening user edits.
- `agoneco projects` lists the projects open in the app. Closing a window is the user's decision; there is no command to close it.
- Responses include `diskRevision`, `editorRevision` and `pendingChanges`. A command result identifies the version it inspected. Reload alone does not save. Exact wait accepts only the current saved/loaded revision; appliedRevision is diagnostic and is not an alias for a newer user edit. Do not report a stale or pending result as verification of the current disk project.

### Copyable apply example (no jq)

Summary: `state --folder → check ready/projectId/workdir → re-read + hash-check project.agoneco → write separate proposal.json → validate → apply --project-id --revision --folder → wait --revision`.

This minimal example renames project metadata through direct apply. For visual edits, use compare and the comparison-bound apply below instead. It runs on the app's own Node (`agoneco node`), with the **exact** work folder as the argument. It stops on errors, a pending/unsaved editor state, a changed disk revision or an existing proposal file; it never forces a save.

```sh
agoneco node /absolute/project-folder <<'JS'
import {readFileSync, writeFileSync} from 'node:fs';
import {resolve} from 'node:path';
import {createHash} from 'node:crypto';
import {spawnSync} from 'node:child_process';
const [folder] = process.argv.slice(2);
const run = args => {
  const r = spawnSync('agoneco', args, {encoding:'utf8', shell:process.platform === 'win32'});
  const result = r.stdout ? JSON.parse(r.stdout) : {};
  if (r.error || r.status !== 0) throw Object.assign(Error(result.error || r.stderr || r.error?.message || 'agoneco command failed'), {details:result});
  return result;
};
const s = run(['state', '--folder', folder]);
if (!s.ready || !s.editor.connected || s.loadedProjectId !== s.projectId || s.pendingChanges || s.unsaved)
  throw Error('Open/synchronize/save first. Inspect state.editor.open and state.recovery.');
const raw = readFileSync(resolve(s.workdir, s.projectFile), 'utf8');
if (createHash('sha256').update(raw).digest('hex') !== s.diskRevision)
  throw Error('Project changed after state. Read state again and rebuild the proposal.');
const proposal = JSON.parse(raw);
proposal.name = 'New title'; // Replace this edit with the user-requested changes.
const file = resolve(s.workdir, 'proposal.json');
writeFileSync(file, JSON.stringify(proposal, null, 2), {flag:'wx'});
run(['validate', file]);
const target = ['--project-id', s.projectId, '--folder', s.workdir];
const applied = run(['apply', file, '--revision', s.diskRevision, '--no-compare', ...target]);
const loaded = run(['wait', '--revision', applied.waitRevision, ...target]);
if (!loaded.ready || loaded.pendingChanges || loaded.unsaved) throw Error('Editor synchronization remains pending');
console.log({projectId:s.projectId, appliedRevision:applied.waitRevision, editorRevision:loaded.editorRevision});
JS
```

### Before / after before applying a visual proposal

Save/synchronize the current editor first. A comparison uses that saved current view as **Before** and your separate proposal as **After**. It does not apply the proposal, move the playhead/selection, add undo steps, or replace the project in the editor. Both halves use the official compositor, the same frame timestamps, pixel scale and checkerboard. This first version requires the same canvas size and fps; compare canvas-setting changes separately. This is visual inspection, not a sound/listening comparison.

```sh
agoneco compare proposal.json --project-id <state.projectId> --revision <state.diskRevision> --folder <state.workdir> --time 1.5
# Or compare multiple times: --from 0 --to 4 --count 4 (default: 4 pairs; max 12).
# Open/inspect the returned PNG. Revise proposal.json and compare again as needed.
agoneco apply proposal.json --project-id <state.projectId> --revision <comparison.baseRevision> --folder <state.workdir> --comparison <comparison.comparisonId>
agoneco wait --revision <apply.diskRevision> --project-id <state.projectId> --folder <state.workdir>
```

The compare result contains `path`, `receipt`, `comparisonId`, `proposalHash`, `baseRevision` and frame/settings metadata. **Actually inspect the image** before applying; a generated receipt does not attest that you looked at it. Apply with that comparison ID checks the exact proposal bytes, baseline revision, original media fingerprints and current editor state. A changed proposal (including formatting), changed baseline/media, unsaved editor changes, expired comparison (30 minutes) or a reopened project requires a fresh comparison. A saved receipt alone cannot authorize a later apply. Fonts replacing an existing font file must use a new font ID so previewing cannot change the live font face.

For visual authoring, use the comparison-bound apply above. The command requires a comparison ID by default. Deliberate metadata edits can explicitly use `--no-compare`; it checks project ID/revision but does **not** attest to comparison. Do not silently omit `--comparison` after a stale-comparison error. After apply, wait and inspect the applied result before official export.

### Errors and recovery

`state` returns a successful diagnostic response even when inspection cannot run (the window is still loading or the user closed it):

```json
{"projectId":"<id>","workdir":"/absolute/project-folder","projectFile":"project.agoneco","app":{"version":"<app version>","apiVersion":5},"ready":false,"editor":{"connected":false,"connections":0,"open":"agoneco open "/absolute/project-folder""},"diskRevision":"<hash>","editorRevision":null,"pendingChanges":true,"timeline":null,"recovery":{"code":"EDITOR_DISCONNECTED","open":"agoneco open "/absolute/project-folder"","keepAppOpen":true,"steps":["Open the project in the AGONECO app (run the open command), then retry state."]}}
```

When the project is not open in the app at all, commands fail with `code:"PROJECT_NOT_OPEN"` (and `APP_NOT_RUNNING` when the app is closed); the error names the `agoneco open` line to run.

| Condition | Response / action |
| --- | --- |
| Project not open, editor still loading, pending sync or conflicts | Check `ready`, `pendingChanges`, `syncError` and `recovery`. Run `agoneco open <folder>` if it is not open, or resolve/save edits, then repeat state. |
| Conflicts awaiting a choice | `state.recovery.code=CONFLICT_PENDING`. Resolve the named item in the editor; retry save and state. Never auto-select a side. |
| Inline/lane effect disagreement | `EFFECT_CONFLICT` includes clip/effect IDs and `lanePath`. Edit that lane in place, preserve its ID and remove the conflicting inline copy. |
| Editor save failed | Inspection returns `SYNC_FAILED` with the save reason; `state.syncError` also reports it. Fix the reported problem, save with Ctrl/Cmd+S, then read state and compare against its current revision. |
| Displayed media changed | `MEDIA_MISMATCH` includes `mediaMismatch.path`, `displayedHash` and `diskHash`. Refresh media in the editor, then read state and create a new comparison; never reuse the old receipt. |
| Wrong project ID | `PROJECT_MISMATCH` (HTTP 409, 428 when missing on apply); export without an ID/revision uses `TARGET_REQUIRED`. Stop and select the intended work folder; do not blindly retry another project. |
| Stale apply revision | A known older base merges (see `merged`/`conflicts`); a comparison-bound apply or an unknown base is refused (exit 1). Re-read state and the project file (project.agoneco, or legacy project.json), then rebuild the proposal. Do not force overwrite. Validation errors include entity IDs, received values and allowed ranges. |
| Output cancelled, editor closed or version changed during output | Exit 1; do not deliver a partial file or describe the request as completed. Read state and retry official inspection/export after recovery. |

For work commands, parse the one JSON object on stdout and check the exit status. A failure carries `next` (the command to run next) unless it gives `recovery.steps`. `agoneco help <command>` explains one command with examples; `AGONECO_PROJECT=<project file>` selects the project for every command of a shell. On failure, retain its structured details and stderr. On successful state, separately require `ready:true`, `loadedProjectId === projectId`, the intended work folder, and no pending/unsaved changes before applying or claiming revision-specific completion. Do not run automatic recovery loops that switch renderers or overwrite newer edits.

While the project is open, ask the editor to render into `renders/` in the project folder:

- `agoneco sheet 0 8 --every 0.5` — a contact sheet, 1200 px wide (`--width` up to 2560): frames in a grid, each labelled with its time and frame number (`--count 12`, `--cols 4`; default: 12 frames over the output range). Look at this first to check timing.
- `agoneco frame 3.3` — one frame, 960 px on its long side (`--width 1920` for more detail) (`--frame 99` for a frame number; no argument = what the user is looking at). Transparent areas show as a dark checkerboard, so white elements and the canvas edge stay visible; add `--transparent` for the bare PNG.
- `agoneco media` — every image, video and audio file of the project on one labelled sheet (name, size, length, which clips use it; `media logo.png intro.mp4` for some of them). Audio files, and videos with sound, also get compact waveform rows aligned at source zero on one common scale based on the longest selected sound, and a `sound` entry in the JSON (`firstSound`, `lastSound`, `loudestAt` in source seconds, `peakDb`) — so you can check a sound before it is on the timeline. `--frames 6` shows six stills across each video. **Check media this way instead of opening image files one by one: one sheet costs far fewer tokens than many images, and videos cannot be opened directly at all.** The JSON answer lists the same details as text.
- `agoneco audio` — every sounding clip as a compact waveform row, aligned at its own start by default (common scale based on the longest selected clip; single clips use their own length). Use `--axis timeline` to inspect actual placement; `--from/--to` also select timeline mode (cannot combine with `--axis relative`). The default timeline view fits selected clips; explicit bounds use the output range for an omitted bound. Select a range with `--from 5 --to 8`, or name clips. Each row is scaled by its volume curve (waveform height on a dB scale, −48…0 dB) with the volume curve drawn on top as a linear line. The JSON gives, per clip, `firstSound`, `lastSound`, `loudestAt` (timeline seconds) and `peakDb` — usually enough to line sounds up with frames without looking at the image. Add `--loudness` to also measure integrated loudness (LUFS) of the mix and of each clip, decoded as the export does (slower); the answer always includes `master` (`gain` dB, `limiter`). Streaming targets ≈ −14 LUFS, podcasts −16, broadcast −23; match by setting clip `volume` or `master.gain`.
- `agoneco state --brief --folder <exact-work-folder>` — the compact form: revisions, readiness, playhead, selection and every clip's id, name, kind, track and times. Use it to find ids; drop `--brief` for every clip setting.
- `agoneco state --folder <exact-work-folder>` — JSON with the project ID, absolute work folder, editor connection, and the user's playhead, selected clips/effect, whether they have unsaved edits, and the whole timeline as the editor loaded it (every clip with its kind, track, time range, sound settings and effects) — use it to confirm your edits took effect. Use it when the user says "here" or "this one".
- `agoneco show 5.7 --note "Is this too fast?"` — points the user at a moment: the editor's agent panel shows that frame and your note with a button to jump there.

- `agoneco export --format mp4 --folder <state.workdir>` — exports the open project's current saved revision (`--project-id`/`--revision` only to pin one) and invokes the editor’s actual File → Export renderer and streams output to `renders/`. Supports `mp4`, `prores-mov`, `png-zip`, `wav`, `mp3`; MP4 accepts `--background '#ffffff'`. Save user edits first. Inspect the same saved revision with sheet/frame, and audio when it has sound (sound-only export needs audio). Failed, cancelled or stale exports are not published as success. Large exports may take time; use `agoneco cancel-export --folder <state.workdir>` to cancel just the export.
- Successful export returns `path`, `receipt`, project ID/revisions, output SHA-256 and settings. Keep the JSON receipt with the delivered file. It records official inspection requests; it does **not** prove that anyone viewed the images or listened to the audio. Report actual visual/listening review separately. An externally generated MP4 is not an AGONECO output and cannot replace this path.

**Completion:** For an editing-only request, deliver the project open in the user’s editor, with the applied revision and official inspection results. When a video is requested, additionally export through the editor, deliver the file and receipt, and say which visual and listening checks actually occurred. Missing synchronization, inspection or requested export means the corresponding work remains pending; do not call an external render a completed AGONECO video.

Rendering happens off-screen with the export renderer (same pixels as the final video) and never moves the user's playhead or selection; the editor's agent panel lists each request so the user sees what you looked at. Offline: use validate and source-media inspection. Clearly report that source inspection does not verify the composited timeline. Do not make your own composite MP4 to stand in for editor verification. Prefer these tools over driving the window.

The app's project service listens only on 127.0.0.1 with a random token known to the editor window; the `agoneco` command reaches the app through an owner-only local socket. Nothing outside the project folder is served or written.

### The full form the editor saves

It keeps everything above and adds editor fields. The parts you are likely to touch:
- `layers[]`: as above, plus `dimensions`, `trackId`, `name`. Text clips also carry a rendered `src`; ignore it (the text is re-rendered).
- Groups are editable scene clips that own media and audio tracks, including empty tracks. A container uses `group:true`; set `tracks[].groupId:<container.id>` and matching member `layers[].groupId`. Empty groups and tracks persist. The group-clip arrow expands contents in place; the track arrow folds only effect lanes. Group audio appears inside the group; the bottom audio section holds main-timeline audio. New library video placement with sound creates a video/audio group; existing saved or agent-authored split clips remain compatible.
- A group is a rectangle that shows its members; anything outside it is cut off, even inside the canvas. Its edge handles crop the visible area (the group's frame field) without moving members; corner handles scale the whole group. Zoom or pan inside a group with the camera zoom effect (frameZoom).
- Groups show cached composite thumbnails and use a full-canvas local stage (canvas dimensions; placement scale 1 covers 100%). Reuse their transparent placeholder source, never a flattened render. Picture effects apply after member compositing; nesting is supported. Use the context menus to move a clip or whole track into/out of a group. Ordinary placement is preserved and parent effects stop applying when removed. `audioMuted` controls descendant sound independently of picture visibility.
- Internal rulers and clip timing fields show group-local time with minor/frame ticks at the current zoom; saved JSON ranges remain in main-timeline seconds. Moving a group moves descendants; trimming hides source content without stretching; explicit group playback-rate edits retime video and audio together. JSON proposals must update descendant ranges, effect ranges and media speeds explicitly alongside `groupSpeed`. Preserve `timing.preRoll:true` on retained internal source before time zero. Dissolving a group releases its member tracks and removes group effects.
- Effects live in `tracks[].effectTracks[].effects[]` as `{id, clipId, start, end, effect, enabled}`. Edit them there, or add a new range as an inline `effects` entry on the layer (a new `id`, or none); identical inline duplicates for the same clip are accepted. Differing duplicates return EFFECT_CONFLICT with the canonical lanePath; edit that lane and preserve its ID.
- `tracks[]` on audio tracks also carry the mixer: `gain` (dB, −40…12), `pan` (−1 left … 1 right), `solo` (while any track is soloed only soloed tracks sound; `visible: false` mutes) and `role` (`voice`, `music`, `effects`). A clip's own `video.pan` pans before its track. Ducking (덕킹, lower while speaking) is a sound effect on an audio clip: `effects: [{"effect": {"preset": "duck", "duckAmount": -12, "duckAttack": 0.3, "duckRelease": 0.6}, "start": 2, "end": 9}]` — inside that range the clip turns down while tracks with `role: "voice"` speak. The editor finds the speech itself and writes the result to `video.duck` (do not write that by hand); it multiplies with `volumeKeys`. Other sound effects work the same way (on audio clips only, active within their range, processed in this order): `gate` (noise gate: `gateThreshold` −90…−10 dB, `gateRange` −80…0 dB), `eq` (`eqLowCut` 0…300 Hz, 0 = off; `eqLow`/`eqMid`/`eqHigh` −12…12 dB at 120 Hz / 1 kHz / 6 kHz), `compressor` (`compThreshold` −60…0 dB, `compRatio` 1…20, `compMakeup` 0…24 dB), `reverb` (`reverbMix`, `reverbSize`, `reverbDamp` 0…1; the echo fades out after the range). Picture effects go on media clips only, sound effects on audio clips only.
- `assets[]` is the media library; `output` is the export range; `fonts[]` lists added font files; `luts[]` lists colour lookup tables (`{id,name,size,data}`; a colour effect's `lut` names one; the user adds a .cube file in the effect panel); `master` (`{"gain": -3, "limiter": true}`) is the whole-mix gain in dB and the -1 dBFS peak limiter (on unless `false`).

## Scenes
Named editing ranges live in project.scenes: {id,name,start,end} in project seconds. They cannot overlap and do not crop rendering. Clips/groups may set sceneId and sceneOverflow (default false); group children inherit membership. Explicit child sceneId is rejected; sceneOverflow may be set per child. Use patch add/set/remove with "scene", and move with "scene" plus by/to to shift the scene and members/effects together. Setting the range only changes the boundaries. Remove keeps the clips; unlink a clip by setting both sceneId and sceneOverflow to null. Invalid edits fail atomically.

Timeline AI search (desktop CLI, explicit indexing only): `agoneco timeline index`, `timeline status`, `timeline cancel`, `timeline search "query" --kind scene|group|clip|all --limit 10`, `timeline seek <kind> <id> --time <local seconds>`. Index returns immediately; poll status until complete. Search uses existing valid vectors only and reports pending targets. elementMatches reuse source-media vectors; sceneMatches use isolated group/scene composites. Scores in these lists are not fused. Results include current time/frame/range and revision; use seek to navigate. audioMatches directly embeds audible source windows (10 seconds, 5-second stride) with Gemma and maps them through trims, speed, loops and group/scene bounds. It has no transcript text or word-exact timing. Scores of different evidence lists are not fused. The asset AI search preference is unchanged.

Reusable scene assets use JSON definitions stored at scene/<name>/scene.json with their media under assets/. The library displays Name.scene; standalone exports remain .agnv. assets import accepts .agnv and copies its contents; assets export of a scene writes an independent project with only required media. Scene metadata includes color, fixed bounds and optional assetId. {"op":"register","scene":ID} registers overlapping clips (whole top-level groups); crossing clips get sceneOverflow:true. {"op":"store","scene":ID} saves a reusable library snapshot. {"op":"place-scene","asset":ID,"at":seconds} makes an independent timeline copy, remapping all internal ids. {"op":"transform","scene":ID,"value":{"x":0,"y":0,"width":640,"height":360}} transforms the scene and its effects proportionally. Scene snapshots reference media by stable assetId; never rewrite their sources by matching names.

## B. Link only

`https://tools.agoneco.com/video/app/#json=` followed by the project JSON, URL-encoded (encodeURIComponent). Chat apps often cut unencoded links at spaces or quotes. Media named by file name make the editor ask the user for those files; an https image URL whose server allows cross-origin requests (or a small data URL) opens without that step.

To continue after the user edited by hand, ask them to use File → Copy for AI agent in the editor and paste the JSON; change it and send a new link.

Example — an image that fades in, floats, then zooms to 1.6× while fading out:

```json
{"name":"Fade, float, zoom out","duration":5,"layers":[{"id":"image","src":"image.png","timing":{"start":0,"end":5},"effects":[{"start":0,"end":1,"effect":{"preset":"enter","transitionMotion":"fade"}},{"start":1,"end":3,"effect":{"preset":"float","strength":1,"speed":1}},{"start":3,"end":5,"effect":{"preset":"zoom","zoomTo":1.6,"curve":"smooth"}},{"start":3,"end":5,"effect":{"preset":"exit","transitionMotion":"fade"}}]}]}
```

https://tools.agoneco.com/video/app/#json=%7B%22name%22%3A%22Fade%2C%20float%2C%20zoom%20out%22%2C%22duration%22%3A5%2C%22layers%22%3A%5B%7B%22id%22%3A%22image%22%2C%22src%22%3A%22image.png%22%2C%22timing%22%3A%7B%22start%22%3A0%2C%22end%22%3A5%7D%2C%22effects%22%3A%5B%7B%22start%22%3A0%2C%22end%22%3A1%2C%22effect%22%3A%7B%22preset%22%3A%22enter%22%2C%22transitionMotion%22%3A%22fade%22%7D%7D%2C%7B%22start%22%3A1%2C%22end%22%3A3%2C%22effect%22%3A%7B%22preset%22%3A%22float%22%2C%22strength%22%3A1%2C%22speed%22%3A1%7D%7D%2C%7B%22start%22%3A3%2C%22end%22%3A5%2C%22effect%22%3A%7B%22preset%22%3A%22zoom%22%2C%22zoomTo%22%3A1.6%2C%22curve%22%3A%22smooth%22%7D%7D%2C%7B%22start%22%3A3%2C%22end%22%3A5%2C%22effect%22%3A%7B%22preset%22%3A%22exit%22%2C%22transitionMotion%22%3A%22fade%22%7D%7D%5D%7D%5D%7D

Compact links from code: `https://tools.agoneco.com/video/app/#p=` + base64url(deflate-raw(JSON)). Node: `zlib.deflateRawSync(JSON.stringify(project)).toString('base64url')`. Python: `zlib.compressobj(9, zlib.DEFLATED, -15)` then `base64.urlsafe_b64encode(...).rstrip(b'=')`. For the English editor use `https://tools.agoneco.com/en/edit/`. The fragment (`#…`) is never sent to the server.

## Project format

JSON Schema: https://tools.agoneco.com/project.schema.json. Times are seconds on the timeline. Everything except `layers` is optional.

| Field | Default | Meaning |
| --- | --- | --- |
| `name` | — | Project name shown in the editor and used when saving. |
| `width`, `height` | 1280, 720 | Canvas pixels, 64–4096. Use 1080×1920 for vertical video. |
| `fps` | 30 | Positive integer; desktop supports direct input such as 120, 144 or 240. |
| `duration` | end of the last clip/effect | Timeline length in seconds. |
| `layers` | — | Clips: images, videos, audio and text. Stacking follows the track rules below, not an independent z-index. |
| `tracks` | one per `trackId` | Optional `[{id, name}]`. Explicit track order takes priority; later tracks draw in front. |

### Stacking order: tracks take priority

- Explicit `tracks[]` order is authoritative. Later media tracks draw in front; reordering `layers[]` alone does not override it. Example: `tracks: [{id:"back"},{id:"front"}]` puts a title on `front` above a photo on `back`, even if the title appears first in `layers[]`.
- Without `tracks[]`, the editor infers track order from each track ID’s first occurrence in `layers[]` (`trackId` defaults to the clip’s `id`). Referenced track IDs missing from an explicit list are appended in first-occurrence order. For predictable stacking, list **all** media tracks explicitly.
- Clips on the same track retain their relative layer order but must not overlap in time. Put overlapping visual elements on separate tracks; change `tracks[]` to change their front/back order. Audio tracks have no visual z-index.

### Layer (clip)

- `id`: letters, digits, `_`, `-`. **Unique in the project** (a repeated clip or track id is refused). Ids are how you and the commands tell clips apart; `name` is for people and may repeat (a name that several clips share matches all of them in commands). Effect ids only need to be unique within their clip.
- `src`: a file path relative to the project folder or a file name the user supplies (`A.png`), an https URL, or a data URL — or `text` instead for a text clip.
- `timing`: `{start, end}`. Images and text default to the whole timeline; videos and audio to their natural length.
- `placement`: `x`, `y` = offset of the clip centre from the canvas centre in px (y grows downward). `scale` 1 = fits inside 80% of the canvas. Also `rotation` (degrees), `flipX`, `flipY`.
- `corners`: four-corner distortion, with keys `tl`, `tr`, `br`, `bl`. Each value is an offset `[dx,dy]` from that corner’s unmodified position, measured in local image width/height **before** placement rotation/flips (not canvas pixels or absolute corner coordinates). Missing = `[0,0]`; x right, y down; each component −1.5…1.5. Example: `{"tl":[0.1,0.1],"tr":[-0.1,0.1],"br":[0,0],"bl":[0,0]}` moves the top corners inward/down. Keep a convex, non-folded quadrilateral and inspect the proposal with compare.
- `mask`: shows only part of a clip's picture, e.g. `{"shape":"ellipse","x":0.2,"y":0.1,"width":0.6,"height":0.8,"feather":0.05}` (`rectangle`/`ellipse`: x, y, width, height; `polygon`: `points` [[u,v],…] with 3–32 corners; all fractions of the picture from its top-left). Outside the shape turns transparent, inside with `invert`; `feather` (soft edge) and `expand` (grow/shrink) are fractions of the picture's shorter side. The mask belongs to the picture, so it moves, scales and rotates with the clip (and with a frame zoom). Set it with `{"op":"set","clip":id,"value":{"mask":{…}}}` and remove it with `"mask":null`.
- `brushMask`: non-destructive paint/erase on a clip or group, e.g. `{"base":"full","strokes":[{"mode":"erase","size":0.1,"points":[[0.2,0.3],[0.8,0.3]]}]}`. `base` is `full` (visible) or `empty` (hidden). Each stroke has `mode` paint/erase, diameter `size` 0.001–1 of the picture's shorter side, optional `opacity` 0–1, and 1–2048 picture-fraction points [u,v] in -1..2. Maximum 64 strokes and 8192 total points. Paint restores alpha; erase removes it, without changing original media. Multiplied by `mask` when both are set. Remove with `"brushMask":null`.
- `blend`: a clip's blend mode, one of `multiply, screen, overlay, darken, lighten, colorDodge, colorBurn, hardLight, softLight, difference, exclusion, add`; omit it (or `normal`) for ordinary layering. It mixes the clip with everything drawn behind it (the tracks below, inside its own group), so put the clip on a track in front of what it should affect. A clip with a light effect is drawn normally, and a shadow or contour stroke is never blended.
- `shadow`: `{color,opacity,blur,distance,angle,size}`; colour as #rrggbb, opacity 0–1, blur 0–200 and distance 0–1000 in canvas pixels; angle 0 = right, 90 = up; size 0.2–3 around the clip centre. Omit it for no shadow. `state.clips[]` exposes placement/corners/shadow for the current clips.
- `light` effect (`{"preset":"light", "light":{…}}`): `light` is `{color,angle,intensity,spread,shade,rim,rimWidth,rimSoftness,relief,reliefDepth,reliefDetail,elevation,blend}`, all optional. `angle` is where the light comes from (0 = right, 90 = up), fixed on the canvas so it stays put while the clip rotates. `intensity` 0–2 brightens the lit side, `spread` 0.05–1 is how far it reaches in, `shade` 0–1 darkens the far side; `rim` 0–2 lights the edge facing the light (`rimWidth`/`rimSoftness` in canvas pixels); `relief` 0–2 shades a bevel of `reliefDepth` px (`reliefDetail` 0–1 adds the picture's own brightness as texture, `elevation` 5–85°); `blend` `screen` (default), `add` or `soft`. Motion over the range: `lightAngleMode` (`ramp` turns `lightTurn` degrees, `spin` turns `lightSpin` °/s; both clockwise unless `lightClockwise: false`, amounts positive), `lightLevelMode` (`ramp` to `lightLevelTo`, `flicker`, `pulse` with `lightRate`/`lightAmount`), `lightColorMode` (`ramp` to `lightColorTo`, `rainbow`, `alternate` at `lightColorRate`). Each ramp has its own curve: `lightAngleCurve`, `lightLevelCurve`, `lightColorCurve`. `lightFade` seconds of fade at the range ends (default 0, none). `lightSource`: a lamp clip id; the light then comes from that clip as either moves. Overlapping light ranges all shine together (up to 4); the upper lane's is the main light. Set `shadow.followLight: true` on the clip to make its shadow fall opposite the playing light.
- `lamp` (instead of `src`): a light-source clip, `{kind,color,intensity,rays,rayLength,rayWidth,spin,clockwise,ghosts,beams,beamLength}`. `kind` `glow` (soft ball), `rays` (starburst of `rays` beams turning `spin` °/s), `flare` (streak plus lens-flare rings when `ghosts`); `beams` 0–2 adds light shafts over the scene: clips on tracks above the lamp are in front of it and block them, casting shadow shafts; clips below are background and get the shafts on top (`beamLength` 0.1–1). Place and animate it like any clip; its size is its placement scale. Unlike other clips, its centre may sit off the canvas, up to a canvas width/height past each edge (placement x within ±1.5 × width, y within ±1.5 × height), for light from off screen; `show: false` hides the lamp itself.
- `video` (video/audio files): `start`/`end` trim inside the source (seconds), `speed` 0.25–8 (pitch kept), `loop`, `muted`, `volume` 0–4 (linear, 1 = as recorded). Placed videos play once with sound unless you say otherwise. Size and length come from the file.
  - Sound shaping: `fadeIn` / `fadeOut` (seconds), `volumeKeys` — a volume curve `[{"time": 0, "volume": 0.2, "curve": "smooth"}, {"time": 1.5, "volume": 1}]` in seconds from the clip start (multipliers 0–2, multiplied by `volume`, interpolated in decibels between points; on a point, the way to the next one is straight by default, `"bend": -1…1` bows it above (+) or below (−) the straight line, `"curve": "smooth"` makes it S-shaped), `pitch` in semitones (−12…12, audio files only — ignored on videos; length unchanged), and `pitchKeys` — a pitch curve `[{"time": 2, "pitch": 0, "curve": "smooth"}, {"time": 2.4, "pitch": -5}]` added to `pitch` (audio files only; timing and length unchanged).
  - **A video's sound is its own clip.** When the editor opens your project, a video clip with sound becomes two clips, as when the user imports a video: the silent picture keeps your id, and its sound becomes `<id>-audio` on track `<trackId>-audio` with the same time, trim and speed. Sound settings you put on the video (`volume`, fades, `volumeKeys`) move to that clip — edit them there afterwards (check with `state`). To place a video's sound yourself, use `"src": "clip.mp4#audio"` (it plays the same file; nothing is extracted). `"muted": true` keeps a video silent and unsplit.
- `trackId`: clips that share a track play one after another on it; give overlapping clips different tracks.
- `effects`: list of `{start, end, effect}` on this clip. `effect` is a preset name or an object with `preset` and options. Effects may overlap; they combine. Instead of `start`/`end` you may give `at` (seconds after the clip starts, default 0) and `duration`: `{"at": 0.65, "duration": 0.33, "effect": {…}}` — no arithmetic on the clip's start. Saved projects store the resulting main-timeline seconds.
- `group: true` (a scene clip owning tracks) and `shape` clips need no `src`: a minimal group is `{"id":"scene","group":true,"trackId":"scene-track","timing":{"start":0,"end":12}}` plus `tracks[].groupId` / `layers[].groupId` on its members; the editor fills frame, placement, effect defaults and the transparent stand-in.

### Effects

| preset | what it does | options |
| --- | --- | --- |
| `enter` | Appear. The range is pinned to the clip start; only its length matters. | transitionMotion, transitionFade, strength, curve |
| `exit` | Disappear. The range is pinned to the clip end; only its length matters. | transitionMotion, transitionFade, strength, curve |
| `shake` | Fast side-to-side shake (strength 1 ≈ ±7 px, ±1.2°). | strength, speed |
| `float` | Slow up-and-down bob (strength 1 ≈ 14 px). | strength, speed |
| `wobble` | Whole image squashes and stretches like jelly. | strength, speed, direction, random |
| `wobble-v2` | Only the surface ripples. | strength, speed, direction, random, surfaceMode |
| `zoom` | Scale from zoomFrom (default: current size) to zoomTo; holds the end size afterwards. | zoomFrom, zoomTo, zoomAnchor, curve, zoomHold |
| `rotate` | Turn from rotateFrom (default: current angle) to rotateTo degrees (default +360); holds the end angle. | rotateFrom, rotateTo, rotatePivotX/Y, curve, rotateHold |
| `trail` | Deterministic historical copies, sampled directly at earlier frames inside the range. | trailCount, trailGap, trailOpacity, trailDecay, animation.trailOpacity |
| `blur` | Premultiplied RGBA blur after geometry, before shadow/light; active only inside the effect range. | blurFrom, blurTo, blurMode, blurAngle, focusX/Y, focusRadius, focusFeather, repeat, animation |
| `key` | Chroma key: pixels whose chroma is close to keyColor turn transparent, so the tracks behind show through; runs before the colour correction. Brightness hardly matters, shadowed screen areas key too. The last active key range on a clip wins. | keyColor (#rrggbb), keySimilarity 0..1 (how far from the key colour still counts as key), keySmoothness 0..1 (soft edge), keySpill 0..1 (removes the key colour cast from what stays), animation (keyframes for the three numbers) |
| `color` | Colour correction of the clip picture, before blur, light and shadow; several colour ranges at one time act as one (offsets add, saturation multiplies). Order: temperature/tint, brightness, contrast, saturation, hue. | brightness -1..1, contrast -1..1, saturation 0..2 (1 = unchanged), hue -180..180 degrees, temperature -1..1 (warm +), tint -1..1 (magenta +), lut (id from the project luts[], applied last), lutMix 0..1, animation (keyframes for each) |
| `pin` | Static PNG contour mesh: source UV pins fix or pull vertices; elastic return is deterministic. | pins[{id,u,v,dx,dy,locked,keys[{t,dx,dy}]}] (keys: pose per pin at t=0..1 of the effect; overrides dx/dy and the style), pinDensity, pinMode, pinRelease, pinFrequency, pinDamping, pinHold, animation.deformation |
| `move` | Slide moveDistance canvas px toward moveAngle (0 right, 90 up); holds the end position. | moveAngle, moveDistance, movePath, moveOrient, curve, moveHold, animation.progress |
| `shapeStyle` | Opacity for any clip (shapeFillOpacityFrom/To = the whole clip, 0..1); on shape clips also fill/stroke colour (shapeFillFrom/To, shapeStrokeFrom/To as #rrggbb) and stroke opacity (shapeStrokeOpacityFrom/To) and dash offset (shapeDashOffsetFrom/To, -200..200). Changes over the segment and holds the last look unless shapeHold is false. Keyframes: animation.shapeFillOpacity, shapeStrokeOpacity (numbers 0..1), shapeDashOffset (-200..200), shapeFillColor, shapeStrokeColor (#rrggbb), and gradientFromX/Y and gradientToX/Y (-2..3, relative to fill bounds). | shapeFillFrom, shapeFillTo, shapeStrokeFrom, shapeStrokeTo, shapeFillOpacityFrom, shapeFillOpacityTo, shapeStrokeOpacityFrom, shapeStrokeOpacityTo, shapeDashOffsetFrom, shapeDashOffsetTo, shapeHold, curve, animation.* |
| `textStyle` | Text fill/stroke colour and opacity, sharing the shapeStyle channels. Opacity affects the text fill and outlines separately, leaving its box background and shadow intact. Keyframes, hold and repeat are supported. | shapeFillFrom, shapeFillTo, shapeStrokeFrom, shapeStrokeTo, shapeFillOpacityFrom, shapeFillOpacityTo, shapeStrokeOpacityFrom, shapeStrokeOpacityTo, shapeHold, curve, animation.* |
| `sprite` | Plays a sprite sheet action on a clip that has sprite. The frame comes from time alone; the last frame holds after the range. | spriteAction, spriteFps, spriteMode (loop, pingpong, once) |
| `frameZoom` | The clip frame stays; the picture inside zooms/pans. x/y name the picture point (0..1) shown at the frame centre; at zoom z keep them within 0.5/z of 0 and 1 (zoom 1.6: 0.3125–0.6875) or transparent edges show. A range goes from viewFrom* (default: the view the previous frameZoom range left, which is zoom 1 at 0.5,0.5 unless that range has viewHold:true) to viewTo*; without viewHold the view snaps back to zoom 1 when the range ends. A frameZoom range works the same stored on the clip or on any visible lane of its track (a "camera" lane is just a lane name); all of a clip's frameZoom ranges form one sequence in start order, and where two overlap the earlier start wins. | viewFromZoom, viewToZoom, viewFromX/Y, viewToX/Y, viewInstant, viewHold, viewControl, viewKeys[{t,zoom,x,y}] (view per moment, t=0..1 of the effect; used when viewControl is keys) |
| `light` | Lights the clip shape from one side during the range (see `light` below). Overlapping light ranges: the upper lane wins. | light, lightAngleMode, lightClockwise, lightTurn, lightSpin, lightAngleCurve, lightLevelMode, lightLevelTo, lightLevelCurve, lightRate, lightAmount, lightColorMode, lightColorTo, lightColorCurve, lightColorRate, lightFade |

- `transitionMotion`: `fade`, `pop`, `slide`, `slide-top`, `slide-left`, `slide-right`, `slide-top-left`, `slide-top-right`, `slide-bottom-left`, `slide-bottom-right` (`slide` = from/to below).
- `curve`: `linear`, `smooth`, `easeIn`, `easeOut`. `zoomAnchor`: `top-left`, `top`, `top-right`, `left`, `center`, `right`, `bottom-left`, `bottom`, `bottom-right`.
- `strength` 0–10 and `speed` 0.1–10 default to 1.

### Text

`"text": {"content": "Hello", "fontSize": 64, "color": "#ffffff", "outlineColor": "#000000", "outlineWidth": 4}`. Options: `fontFamily` (`sans-serif`, `serif`, `monospace`, `pretendard`, `noto-sans-kr`, `nanum-myeongjo`, `gowun-dodum`, `black-han-sans`, `do-hyeon`, `jua`, `nanum-pen`), `bold`, `background` (`#rrggbb` or null), `backgroundOpacity`, `align`, `maxWidth` (fraction of canvas width), `role` (`subtitle` places it at the bottom), `reveal` (`words` reveals word by word, `chars` character by character), `count` (`{"from":0,"to":1250}` counts the first number in the text). The optional `label` object adds nameplate padding, rounded background and an image icon: `{"paddingX":24,"paddingY":12,"radius":12,"icon":"icon.png","iconSize":48,"gap":12,"side":"left"}`. Set `background` for the plate; omit `box` for content sizing. Icon media is packaged and relinked like image fills. Font size is in canvas pixels; give `placement.x/y` to move it.

Word art keeps editable text. Fill: `colorTo`, `gradientAngle`, `gradientType` (linear/radial/angular), `gradientFrom` and `gradientTo` (both null or both {x,y} fractions from -2 to 3), `gradientStops` (up to 6 interior `{offset:0.01..0.99,color}` stops, strictly increasing), `fillOpacity`. Extra `outlines` (up to 4) are `{color,width:1..30,gap:0..20,opacity:0..1}`, ordered outward beyond the existing outline. Extra `shadows` (up to 4) are `{color,blur:0..60,offsetX:-120..120,offsetY:-120..120,opacity:0..1}`.

Text and shapes share surface relief: text uses `bevelSize` (0..20, zero disables), `bevelStrength` (0..1), `bevelAngle` (0..360), `bevelMode` (bevel/emboss), and `innerShadow` (same shadow object, null, or an ordered array of 1..4 shadows). Inner shadows accept optional `spread` (-60..60, default 0) optional `blendMode` (same modes as fills), and `visible` set to false to retain it without rendering. Shapes put these fields in `shape.surface`. Animate text fill and outline colour/opacity with a `textStyle` effect; it uses the existing shapeStyle colour/opacity channel names. Native clip motion effects still apply. Text supports up to eight additional `fills`; shapes use `shape.paint.fills`. Each fill has type (solid/linear/radial/angular/image), color/colorTo (#rrggbb), angle (0..360), opacity (0..1), visible, and blendMode (the clip blend mode names). Gradient fills also accept stops: up to six {offset:0.01..0.99,color} entries in strictly increasing order, between color and colorTo. Optional from and to points {x,y} use fractions of the fill bounds (-2..3); supply both to replace automatic angle-based placement. To animate an additional gradient, give that fill a unique id and set gradientFillId on its shapeStyle or textStyle effect; removing the fill leaves the effect without a target. Image fills require src (a static PNG/JPEG/WebP media reference); mode is cover/fit/stretch/clip/repeat, scaleX/scaleY are 0.01..20, offsetX/offsetY are -32768..32768 canvas pixels, gapX/gapY are 0..4096 pixels, interlace is 0..1 of the repeat tile and interlaceAxis is x or y (default x). align is topLeft/top/topRight/left/center/right/bottomLeft/bottom/bottomRight for cover, fit and stretch (default center). Angle rotates the image freely. These media files are packaged, mapped and retained like ordinary assets, including hidden fills and history. Array order is bottom to top; base alpha and glyph/shape boundaries remain unchanged. Shapes support the same gradient/texture fields in `shape.paint`, plus up to four inward `shape.outlines` rings and solid/dash/dot/custom `shape.strokeStyle`; custom uses `strokeDashArray` (1..32 nonnegative lengths, at least one positive). Open paths accept `strokeCap` (butt/round/square) and `strokeJoin` (miter/round/bevel), defaulting to round for existing projects; `strokeMiterLimit` is 1..1000 (default 4). Open paths accept `startArrow` and `endArrow` as a built-in style name or {type,scale,rotation}; omission means none. Arrow decorations do not yet support mesh deformation. A corner pin whose projection horizon crosses the expanded arrow bounds is rejected.

### Example: "make an ending from A.png that shakes and then disappears"

```json
{
  "name": "Ending",
  "width": 1280,
  "height": 720,
  "fps": 30,
  "duration": 4,
  "layers": [
    {
      "id": "logo",
      "src": "A.png",
      "placement": {
        "x": 0,
        "y": -40,
        "scale": 0.7
      },
      "timing": {
        "start": 0,
        "end": 4
      },
      "effects": [
        {
          "start": 0,
          "end": 0.6,
          "effect": {
            "preset": "enter",
            "transitionMotion": "pop",
            "curve": "easeOut"
          }
        },
        {
          "start": 0.6,
          "end": 3,
          "effect": {
            "preset": "shake",
            "strength": 1.2,
            "speed": 1
          }
        },
        {
          "start": 3,
          "end": 4,
          "effect": {
            "preset": "exit",
            "transitionMotion": "fade",
            "curve": "easeIn"
          }
        }
      ]
    },
    {
      "id": "thanks",
      "text": {
        "content": "Thanks for watching",
        "fontFamily": "black-han-sans",
        "fontSize": 64,
        "color": "#ffffff",
        "outlineColor": "#000000",
        "outlineWidth": 4
      },
      "placement": {
        "x": 0,
        "y": 250
      },
      "timing": {
        "start": 0.8,
        "end": 4
      },
      "effects": [
        {
          "start": 0.8,
          "end": 1.4,
          "effect": {
            "preset": "enter",
            "transitionMotion": "slide"
          }
        },
        {
          "start": 3,
          "end": 4,
          "effect": {
            "preset": "exit",
            "transitionMotion": "fade"
          }
        }
      ]
    }
  ]
}
```

### Limits

- No fixed non-text media clip, track, or library asset count; up to 16 ranges per effect lane. Automatic audio clips, tracks, and assets are added as needed. Use validate for the actual totals.
- Unknown presets, out-of-range numbers or bad colours stop the project from opening and the editor shows the reason.

Link (hand-written form) for the example above: https://tools.agoneco.com/video/app/#json=%7B%22name%22%3A%22Ending%22%2C%22width%22%3A1280%2C%22height%22%3A720%2C%22fps%22%3A30%2C%22duration%22%3A4%2C%22layers%22%3A%5B%7B%22id%22%3A%22logo%22%2C%22src%22%3A%22A.png%22%2C%22placement%22%3A%7B%22x%22%3A0%2C%22y%22%3A-40%2C%22scale%22%3A0.7%7D%2C%22timing%22%3A%7B%22start%22%3A0%2C%22end%22%3A4%7D%2C%22effects%22%3A%5B%7B%22start%22%3A0%2C%22end%22%3A0.6%2C%22effect%22%3A%7B%22preset%22%3A%22enter%22%2C%22transitionMotion%22%3A%22pop%22%2C%22curve%22%3A%22easeOut%22%7D%7D%2C%7B%22start%22%3A0.6%2C%22end%22%3A3%2C%22effect%22%3A%7B%22preset%22%3A%22shake%22%2C%22strength%22%3A1.2%2C%22speed%22%3A1%7D%7D%2C%7B%22start%22%3A3%2C%22end%22%3A4%2C%22effect%22%3A%7B%22preset%22%3A%22exit%22%2C%22transitionMotion%22%3A%22fade%22%2C%22curve%22%3A%22easeIn%22%7D%7D%5D%7D%2C%7B%22id%22%3A%22thanks%22%2C%22text%22%3A%7B%22content%22%3A%22Thanks%20for%20watching%22%2C%22fontFamily%22%3A%22black-han-sans%22%2C%22fontSize%22%3A64%2C%22color%22%3A%22%23ffffff%22%2C%22outlineColor%22%3A%22%23000000%22%2C%22outlineWidth%22%3A4%7D%2C%22placement%22%3A%7B%22x%22%3A0%2C%22y%22%3A250%7D%2C%22timing%22%3A%7B%22start%22%3A0.8%2C%22end%22%3A4%7D%2C%22effects%22%3A%5B%7B%22start%22%3A0.8%2C%22end%22%3A1.4%2C%22effect%22%3A%7B%22preset%22%3A%22enter%22%2C%22transitionMotion%22%3A%22slide%22%7D%7D%2C%7B%22start%22%3A3%2C%22end%22%3A4%2C%22effect%22%3A%7B%22preset%22%3A%22exit%22%2C%22transitionMotion%22%3A%22fade%22%7D%7D%5D%7D%5D%7D

More examples: https://tools.agoneco.com/examples/shake-and-vanish-ending.json, https://tools.agoneco.com/examples/fade-float-zoom-out.json, https://tools.agoneco.com/examples/zoom-and-pan.json
