# FotoMagico Document REST API — Reference

External reference for the FotoMagico data-model REST API: enable it, authenticate, and query or
construct `.fms` slideshow documents programmatically. This is the **canonical** endpoint + DTO
reference — for a hands-on walkthrough see [TESTING.md](TESTING.md); for internal architecture see
[README.md](README.md).

> **Maintainers:** this document is the public contract. When the API gains or changes endpoints or
> DTOs, update this file (and [TESTING.md](TESTING.md)) in the same change.

API version: **v1** · Status: Phase 1 (structural backbone).

---

## 1. Base URL & versioning

```
http://127.0.0.1:50000/v1
```

The server is local-only (loopback) and runs inside the FotoMagico macOS app. The API is
**path-versioned**: all endpoints live under `/v1`. Breaking changes will ship under `/v2` while
`/v1` keeps working, so pin to a version in your base URL.

## 2. Enabling the API

The API is **off by default**. Enable it and read the access token from the app's user defaults
(bundle id `com.boinx.FotoMagico6.macOS`):

```bash
# enable
defaults write com.boinx.FotoMagico6.macOS FMWebServer-editingAPIEnabled -bool YES
# read the auto-generated bearer token
defaults read com.boinx.FotoMagico6.macOS FMWebServer-apiToken
```

These settings are intended to be surfaced in FotoMagico's Preferences. The app must be running.

## 3. Authentication

Every `/v1` request requires a bearer token:

```
Authorization: Bearer <token>
```

Missing/invalid token → `401 unauthorized`. (The token is generated once and persists; treat it as a
secret.)

## 4. Conventions

- **Content type:** request and response bodies are JSON. Send `Content-Type: application/json` on
  requests with a body.
- **Identifiers:** documents are addressed by `id` (the document's settings UUID). Slides, layers,
  chapters, and audio objects are addressed by their stable `uuid`. The literal id **`current`**
  resolves to the most recently active/created document (works even while the app is in the
  background).
- **Patch semantics:** `PATCH` bodies are partial — only the fields you include are changed; omitted
  fields are left untouched. `PUT` (chapter only) is create-or-replace.
- **Status codes:** `200` OK · `201` Created · `204` No Content · `400` bad request (malformed body /
  params) · `401` unauthorized · `403` media folder not authorized · `404` not found · `405` method
  not allowed · `409` conflict · `500` internal error.
- **Errors:** every failed request returns a JSON envelope:

  ```json
  { "error": "not_found", "message": "No open document with id 'abc'.", "details": null }
  ```

  Branch on the machine-readable `error` code, not the human `message`. Codes: `bad_request`,
  `unauthorized`, `authorization_required`, `not_found`, `method_not_allowed`, `conflict`,
  `internal_error`.

## 5. Media & the sandbox

FotoMagico is sandboxed, so it can only read media files it has been granted access to. Media is
referenced by **percent-encoded `file://` URL** (e.g. `file:///Users/me/Pictures/sky.jpg`).

- Grant access to a folder once via `POST /v1/media/authorize-folder` (opens a folder chooser in the
  app); access is **recursive** over that folder's subtree and persists across launches.
- If you reference a file outside every authorized folder, the request fails with
  `403 authorization_required` and a `details.path` of the offending URL — authorize its parent
  folder, then retry.
- Text layers need no media and no authorization.

---

## 6. Endpoints

All paths are relative to the base `http://127.0.0.1:50000/v1`.

### Documents

| Method | Path | Request body | Response |
|--------|------|--------------|----------|
| GET    | `/documents` | — | `200` [`FMDocumentSummaryDTO`] |
| POST   | `/documents` | `FMDocumentCreateDTO` | `201` `FMDocumentSummaryDTO` (opens an editor window) |
| GET    | `/documents/{docId}` | — | `200` `FMDocumentDTO` (deep: settings + slides) |
| DELETE | `/documents/{docId}` | — | `204` · `409` if unsaved (pass `?force=true` to discard) |
| POST   | `/documents/{docId}/save` | `FMSavePathDTO` | `200` `FMSavePathDTO` |

Notes: `POST /documents/{docId}/save` with `{}` saves in place (requires the document to have a prior
on-disk location, else `409`); pass `{"path":"…"}` to save to a specific location (must be writable
under the sandbox).

### Export

| Method | Path | Request body | Response |
|--------|------|--------------|----------|
| POST   | `/documents/{docId}/export` | `FMExportCreateDTO` | `202` `FMExportJobDTO` |
| GET    | `/exports` | — | `200` [`FMExportJobDTO`] |
| GET    | `/exports/{jobId}` | — | `200` `FMExportJobDTO` |
| DELETE | `/exports/{jobId}` | — | `200` `FMExportJobDTO` (the resulting state) |

Rendering a slideshow takes minutes, so **export is asynchronous**: `POST` validates the request,
starts the export and returns `202` with a job. Poll `GET /exports/{jobId}` until `state` is no longer
`"running"`; `DELETE` cancels a running job (and returns it, rather than `204`, so you see the outcome).
Jobs live in memory for the app session — the last 20 are kept, so a finished job can still be read.

`POST` fails with `409 conflict` when the export cannot be started: another export is already running
(**only one at a time**), the document is not in authoring mode (playback in progress), the document has
missing media files, or the current license does not permit exporting / this preset / the ProRes codecs.
Documents containing DRM-protected media are **not** rejected — like the Share assistant, they just
export without the protected content.

The destination folder must exist and be writable under the sandbox — the app container, or a folder
granted via `POST /media/authorize-folder`. Otherwise you get `403 authorization_required`. An existing
file at the destination is replaced.

**Progress window.** By default the app shows a small progress window while exporting (an export
triggered over REST would otherwise be completely invisible). It appears about a second in, so short
exports never flash it, and it carries a Cancel button — cancelling there is equivalent to
`DELETE /exports/{jobId}`, so the job simply reports `state: "canceled"` on your next poll. Pass
`"showProgress": false` to suppress the window for unattended use.

### Media authorization

| Method | Path | Request body | Response |
|--------|------|--------------|----------|
| GET    | `/media/authorized-folders` | — | `200` [`FMAuthorizedFolderDTO`] |
| POST   | `/media/authorize-folder` | — | `200` `FMAuthorizedFolderDTO` · `409` if cancelled |

`POST /media/authorize-folder` presents a modal folder chooser in the app and blocks until the user
responds — call it interactively, not from an unattended client.

### Settings

| Method | Path | Request body | Response |
|--------|------|--------------|----------|
| GET    | `/documents/{docId}/settings` | — | `200` `FMSettingsDTO` |
| PATCH  | `/documents/{docId}/settings` | `FMSettingsPatchDTO` | `200` `FMSettingsDTO` |

### Slides

| Method | Path | Request body | Response |
|--------|------|--------------|----------|
| GET    | `/documents/{docId}/slides` | — | `200` [`FMSlideDTO`] |
| POST   | `/documents/{docId}/slides?index={n}` | `FMSlideCreateDTO` | `201` `FMSlideDTO` |
| GET    | `/documents/{docId}/slides/{slideId}` | — | `200` `FMSlideDTO` |
| PATCH  | `/documents/{docId}/slides/{slideId}` | `FMSlidePatchDTO` | `200` `FMSlideDTO` |
| DELETE | `/documents/{docId}/slides/{slideId}` | — | `204` |
| POST   | `/documents/{docId}/slides/reorder` | `FMReorderDTO` | `200` [`FMSlideDTO`] |

`POST /slides` appends by default; `?index={n}` inserts at position `n` (0-based, clamped). The
optional `layers` array in `FMSlideCreateDTO` seeds layers on the new slide. `reorder` requires the
`order` array to be a permutation of the current slide uuids (else `409`/`400`).

### Transition (one per slide)

| Method | Path | Request body | Response |
|--------|------|--------------|----------|
| GET    | `/documents/{docId}/slides/{slideId}/transition` | — | `200` `FMTransitionDTO` |
| PATCH  | `/documents/{docId}/slides/{slideId}/transition` | `FMTransitionPatchDTO` | `200` `FMTransitionDTO` |

### Chapter (zero or one per slide)

| Method | Path | Request body | Response |
|--------|------|--------------|----------|
| GET    | `/documents/{docId}/slides/{slideId}/chapter` | — | `200` `FMChapterDTO` · `404` if none |
| PUT    | `/documents/{docId}/slides/{slideId}/chapter` | `FMChapterPatchDTO` | `200` `FMChapterDTO` (create-or-update) |
| PATCH  | `/documents/{docId}/slides/{slideId}/chapter` | `FMChapterPatchDTO` | `200` `FMChapterDTO` · `404` if none |
| DELETE | `/documents/{docId}/slides/{slideId}/chapter` | — | `204` |

`PUT` creates the chapter if the slide has none, then applies the fields. `PATCH` only updates an
existing chapter (404 otherwise).

### Layers (visual: image / video / text)

| Method | Path | Request body | Response |
|--------|------|--------------|----------|
| GET    | `/documents/{docId}/slides/{slideId}/layers` | — | `200` [`FMLayerDTO`] |
| POST   | `/documents/{docId}/slides/{slideId}/layers?index={n}` | `FMLayerCreateDTO` | `201` `FMLayerDTO` |
| GET    | `/documents/{docId}/slides/{slideId}/layers/{layerId}` | — | `200` `FMLayerDTO` |
| PATCH  | `/documents/{docId}/slides/{slideId}/layers/{layerId}` | `FMLayerPatchDTO` | `200` `FMLayerDTO` |
| DELETE | `/documents/{docId}/slides/{slideId}/layers/{layerId}` | — | `204` |

`image`/`video` layers require a `file://` `mediaURL` (see §5); `text` layers require a `text` string
(styled with the app's default font/color/alignment). `map` layers cannot be created via this API
version. Layer index is the stacking order on the slide.

### Audio (per slide)

| Method | Path | Request body | Response |
|--------|------|--------------|----------|
| GET    | `/documents/{docId}/slides/{slideId}/audio` | — | `200` [`FMAudioDTO`] |
| POST   | `/documents/{docId}/slides/{slideId}/audio` | `FMAudioCreateDTO` | `201` `FMAudioDTO` |
| PATCH  | `/documents/{docId}/slides/{slideId}/audio/{audioId}` | `FMAudioPatchDTO` | `200` `FMAudioDTO` |
| DELETE | `/documents/{docId}/slides/{slideId}/audio/{audioId}` | — | `204` |

### Effects (per slide and per layer)

Effects are **fixed slots** — you don't create or delete them, you enable + configure them
(`{"enabled":false}` turns one off). The same shape applies to a slide and to a layer. `{effectType}`
is an `FMEffectKind` value (unknown → `404`).

| Method | Path | Request body | Response |
|--------|------|--------------|----------|
| GET    | `/documents/{docId}/slides/{slideId}/effects` | — | `200` [`FMEffectDTO`] |
| GET    | `/documents/{docId}/slides/{slideId}/effects/{effectType}` | — | `200` `FMEffectDTO` |
| PATCH  | `/documents/{docId}/slides/{slideId}/effects/{effectType}` | `FMEffectPatchDTO` | `200` `FMEffectDTO` |
| GET    | `/documents/{docId}/slides/{slideId}/layers/{layerId}/effects` | — | `200` [`FMEffectDTO`] |
| GET    | `/documents/{docId}/slides/{slideId}/layers/{layerId}/effects/{effectType}` | — | `200` `FMEffectDTO` |
| PATCH  | `/documents/{docId}/slides/{slideId}/layers/{layerId}/effects/{effectType}` | `FMEffectPatchDTO` | `200` `FMEffectDTO` |

**Effect catalog.** Every effect has `enabled` (Bool) and `amount` (an **envelope** — see
[Envelopes](#envelopes) below; on `GET` always the point array, on `PATCH` either a scalar shorthand
or the array). Additional parameters by `type` (a `PATCH` includes only the ones you want to change):

| `type` | parameters |
|--------|------------|
| `colorCorrection` | `tint`(`#RRGGBBAA`), `saturation`, `contrast`, `gamma` |
| `grayscale` | `red`, `green`, `blue` |
| `sepia` | `tone` |
| `blur` | `radius` |
| `vignette` | `factor`, `radius`, `feather` |
| `stroke` | `width`, `placement`(`inside`/`centered`/`outside`), `color`(`#RRGGBBAA`) |
| `shadow` | `blurRadius`, `offsetX`, `offsetY`, `color`(`#RRGGBBAA`) |
| `comic`, `watercolor`, `etching`, `woodcut` | `smoothing`, `detail`, `inkStroke`, `inkFill`, `colorSteps`, `colorFill`, `algorithm` |
| `mask` | `maskType`(`none`/`rect`/`oval`/`image`), `maskWidth`, `maskHeight`, `feather`, `opacity` |
| `background` | `shape`(`box`/`speechBubble`/`thoughtBubble`/`actionBubble`), `color`(`#RRGGBBAA`), `paddingX`, `paddingY`, `cornerRadius`, `arrowVectorX`, `arrowVectorY`, `arrowWidth`, `arrowBend`, `spikeLength`, `spikeJitter` |
| `film` | `grain`, `scratches`, `blotches`, `jitter`, `flicker` |
| `video` | `lineWidth`, `lineContrast`, `flicker` |
| `lensDistortion` | `centerX`, `centerY`, `factor`, `radius` |
| `chromaKey` | `despillColor`(`#RRGGBBAA`), `despillFactor` (hue/sat/luma masking is envelope-based — later phase) |
| `blendMode` | `mode`(`normal`/`add`/`subtract`/`lighten`/`darken`) |
| `backgroundRemoval` | `quality`(`accurate`/`balanced`/`fast`) |
| `cubeLUT` | `fileURL` (`file://` URL of a `.cube` LUT; imported under the sandbox folder grants like other media) |

---

## 7. Data types (DTOs)

JSON objects. In create/patch types, **optional** fields may be omitted (patch = leave unchanged;
create = use the model default). Colors are `#RRGGBBAA` hex strings. Times are seconds (Double).

### Document

**FMDocumentSummaryDTO** — `id:String`, `title:String`, `subtitle:String`, `author:String`,
`organization:String`, `slideCount:Int`, `isCurrent:Bool`.

**FMDocumentDTO** — `id:String`, `settings:FMSettingsDTO`, `slides:[FMSlideDTO]`.

**FMDocumentCreateDTO** — `title:String?`, `width:Int?`, `height:Int?`, `backgroundColor:String?`.

**FMSavePathDTO** — `path:String?` (request: target file path, or omit to save in place; response:
the path saved to).

### Settings

**FMSettingsDTO** — `id:String`, `width:Int`, `height:Int`, `backgroundColor:String`, `loops:Bool`,
`resizeBehavior:String` (`"none"`/`"crop"`/`"letterbox"`), `playbackVariant:String?` (`null` = play all
variants), `variants:[String]` (read-only list of selectable variants), `background:FMSettingsLayerDTO`,
`foreground:FMSettingsLayerDTO`, `title:String`, `subtitle:String`, `notes:String`, `author:String`,
`organization:String`, `copyright:String`, `url:String`, `email:String`, `watermarkString:String?` (text
burned into exported video, `null` if none), `mutedTracks:[Int]` (audio tracks muted on export: 0 = music,
1 = effects, 2 = narration).

**FMSettingsLayerDTO** — the document-wide background or foreground image layer (full-stage, non-animated;
background below all slides, foreground above as a watermark): `mediaURL:String?` (file:// URL of the source
image, `null` if none), `transform:FMTransformDTO`, `opacity:Double` (0…1), `locked:Bool`, `hidden:Bool`.

**FMSettingsPatchDTO** — all of `FMSettingsDTO` except `id`, `variants`, each optional. `background` and
`foreground` take an **FMSettingsLayerPatchDTO** (`mediaURL`/`transform`/`opacity`/`locked`/`hidden`, each
optional; for `mediaURL` send a file:// URL to set the image or `""` to clear it). For `playbackVariant`,
send a variant name to select it or `""` to clear (play all variants). For `watermarkString`, send a string
to set or `""` to clear. `mutedTracks` replaces the whole muted-track list (`[]` unmutes all). Omitting a
field leaves it unchanged.

### Slide

**FMSlideDTO** — `uuid:String`, `index:Int`, `name:String`, `notes:String`, `duration:Double`,
`continueType:Int` (see §8), `animated:Bool`, `transition:FMTransitionDTO`, `chapter:FMChapterDTO?`,
`layers:[FMLayerDTO]`, `audio:[FMAudioDTO]`.

**FMSlideCreateDTO** — `duration:Double?`, `continueType:Int?`, `animated:Bool?`,
`transition:FMTransitionPatchDTO?`, `layers:[FMLayerCreateDTO]?`.

**FMSlidePatchDTO** — `name:String?`, `notes:String?`, `duration:Double?`, `continueType:Int?`,
`animated:Bool?`.

**FMReorderDTO** — `order:[String]` (all current slide uuids, in the desired order).

### Transition

**FMTransitionDTO** — `type:Int` (see §8), `duration:Double`, `angle:Double`, `softness:Double`,
`amount:Double`, `direction:Int`, `option:Int`, `color:String` (`#RRGGBBAA`, `""` if unset),
`pageCurlAngle:Double`, `pageCurlShadow:Double`, `pageCurlBacksideColor:String` (`#RRGGBBAA`). The
extra parameters apply to specific transition types (wipes use `angle`/`softness`/`direction`,
page-curl uses `pageCurl*`); `direction`/`option` are transition-specific raw integers.
**FMTransitionPatchDTO** — same fields, all optional; for `color`, an empty string clears it.

### Chapter

**FMChapterDTO** — `uuid:String`, `name:String`, `color:String` (`#RRGGBBAA`, or `""` if none),
`notes:String`, `loops:Bool`, `expanded:Bool`.
**FMChapterPatchDTO** — `name:String?`, `color:String?` (empty string clears the color), `notes:String?`,
`loops:Bool?`, `expanded:Bool?`.

### Layer

**FMTransformDTO** — `x:Double`, `y:Double` (stage coordinates), `zoom:Double` (scale relative to the
stage), `rotation:Double` (degrees).

Common layer fields live on the root DTO; everything type-specific lives in a per-kind sub-struct
(`image`/`video`/`text`/`map`). Exactly one sub-struct is populated, matching `kind`.

**FMLayerDTO** (read) — `uuid:String`, `kind:FMLayerKind` (see §8), `opacity` (an **envelope** over the
layer's lifetime — array of `{x,y}` points on read, see [Envelopes](#envelopes)),
`startTransform:FMTransformDTO`, `finishTransform:FMTransformDTO`, `animationSpeed` (an envelope — the
"Animation Speed" time-warp curve that drives interpolation between start & finish; x is fractional
lifetime), plus one of: `image:FMImageParamsDTO?`, `video:FMVideoParamsDTO?`, `text:FMTextParamsDTO?`,
`map:FMMapParamsDTO?`.

Layers always have exactly two transforms (`startTransform`/`finishTransform`); the data model permits
more keyframes but the app has never used them, so the API exposes just the two endpoints plus the single
`animationSpeed` envelope that warps time between them.

Each kind has a read / create / patch sub-struct trio (some are empty placeholders today):

- **image** — `FMImageParamsDTO` / `FMImageParamsCreateDTO` = `{ mediaURL:String }` (`file://`);
  `FMImageParamsPatchDTO` = `{}` (no patchable image params yet).
- **video** — `FMVideoParamsDTO` (read, all present) / `FMVideoParamsCreateDTO` (`mediaURL` required +
  playback optional) / `FMVideoParamsPatchDTO` (playback optional). Fields: `mediaURL:String`,
  `inPoint:Double`, `outPoint:Double` (0 = natural end), `volume:Double` (0…1), `loops:Bool`,
  `duckingEnabled:Bool`, `duckingFactor:Double` (0…1), `playbackSpeed:Double` (1.0 = normal),
  `fadeInOffset:Double` (video's only fade control).
- **text** — `FMTextParamsDTO` / `FMTextParamsCreateDTO` = `{ text:String }`; `FMTextParamsPatchDTO` =
  `{ text:String? }` (preserves existing styling). Plain string; rich styling is a later phase.
- **map** — **read-only.** `FMMapParamsDTO` = `sourceType:String` (`"appleMapKit"`/`"customImage"`),
  `mapType:String` (`"standardLight"`/`"standardDark"`/`"satellite"`/`"hybrid"`/`"custom"`), `showPOI:Bool`,
  `zoomRange:Double?` (null if unset), `region:FMMapRegionDTO`, `mediaURL:String?` (the user's source image
  only when `sourceType=="customImage"`, else null). `FMMapParamsCreateDTO`/`FMMapParamsPatchDTO` are empty —
  map layers **cannot be created or patched** (`kind:"map"` on create → `400`; map params in a PATCH are
  ignored). This is deliberate: a map is a **baked bitmap** rendered by MapKit in the interactive Map Assistant,
  and its `region`/`mapType`/etc. are *metadata describing that image*, not inputs that regenerate it — so
  writing them would desync the declared region from the displayed picture. We expose them for reading/describing
  a map only. `FMMapRegionDTO` = `{ center:FMCoordinateDTO{latitude,longitude}, span:FMCoordinateSpanDTO{latitudeDelta,longitudeDelta} }`,
  in degrees. The region is computed to be correct for maps that **cross the 180th meridian** (center longitude
  near ±180, with the true longitude span rather than a wrapped value). Because the underlying projection is Web
  Mercator, latitude coverage is inherently clamped to **±~85.05°**, so a map reaching a pole reports up to that
  limit, not ±90°.

**FMLayerCreateDTO** — `kind:FMLayerKind`, `opacity` (envelope value — scalar or `{x,y}` array, optional;
see [Envelopes](#envelopes)), `startTransform:FMTransformDTO?`,
`finishTransform:FMTransformDTO?`, `animationSpeed` (envelope value, optional), plus the create sub-struct
matching `kind` (`image`/`video`/`text`/`map`). With no transforms, image/video get a default fit + Ken
Burns animation.

**FMLayerPatchDTO** — `opacity` (envelope value — scalar or `{x,y}` array, optional), `startTransform:FMTransformDTO?`,
`finishTransform:FMTransformDTO?`, `animationSpeed` (envelope value, optional), plus the patch sub-struct matching `kind`
(`image`/`video`/`text`/`map`). Applying a sub-struct to the wrong kind returns `400` (for sub-structs
that carry parameters); the empty image/map patch sub-structs are accepted as no-ops.

### Audio

**FMAudioDTO** — `uuid:String`, `name:String` (on create, taken from the file's metadata title when it has one,
otherwise the filename — see below), `track:Int` (see §8), `mediaURL:String?`,
`volume:Double` (0…1, master level), `volumeEnvelope:FMEnvelopeDTO?` (optional time-varying volume
curve — `null` when none; see [Envelopes](#envelopes)), `inPoint:Double`, `outPoint:Double`
(0 = natural end), `loops:Bool`, `duckingEnabled:Bool`, `duckingFactor:Double` (0…1), `lockedTiming:Bool`,
`playbackSpeed:Double` (1.0 = normal), and the fade fields: `fadeInAutomatic:Bool`, `fadeInOffset:Double`,
`fadeInDuration:Double`, `fadeOutAutomatic:Bool`, `fadeOutOffset:Double`, `fadeOutDuration:Double`.
When `…Automatic` is true the fade follows the slide's transition at that checkpoint; when false,
`…Offset` (start) + `…Duration` (ramp) define it manually. (`volume` is the overall level; `volumeEnvelope`,
when present, additionally shapes it over the clip's lifetime.) Also `audioMarkers:[FMAudioMarkerDTO]`
(always emitted as full `{time,active}` objects, sorted by time).

**FMAudioMarkerDTO** — a playback marker: `time:Double` (seconds), `active:Bool` (only active markers fire).
The model's authoring-only `name` is not exposed. **Polymorphic on write:** each array element may be a bare
number (the `time`, with `active` defaulting to `true`) *or* the full object `{"time":…,"active":…}` — and the
two may be mixed in one array. On read it is always the full object form.

**FMAudioCreateDTO** — `track:Int`, `mediaURL:String` (required, `file://`), and any of the above
(except `uuid`/`name`/`mediaURL`) as optionals. `volumeEnvelope` accepts an envelope value (scalar shorthand
or `{x,y}` array; see [Envelopes](#envelopes)). `audioMarkers` takes the marker list (shorthand or full).
On create the server imports the file's metadata — title, artists, album and source URLs — exactly as the
authoring insert path does, so `name` comes back as the song title when the file has one, and the audio-credits
text tokens work for API-created documents. `artists`, `album` and `urls` are stored on the model but are not
part of `FMAudioDTO` yet, so they are currently only visible in the saved document.

**FMAudioPatchDTO** — all of the FMAudioDTO playback/fade fields as optionals (no `uuid`/`name`/`track`/
`mediaURL`). `volumeEnvelope` accepts an envelope value; sending `[]` clears it (back to `null`). `audioMarkers`
replaces the whole marker list; `[]` removes all markers.

### Effect

Each effect kind has its **own small DTO** (only its own fields) rather than one big union. Reads
return `type` + `enabled` + `amount` + that kind's parameters; the read DTOs are
`FMColorCorrectionEffectDTO`, `FMGrayscaleEffectDTO`, `FMSepiaEffectDTO`, `FMBlurEffectDTO`,
`FMVignetteEffectDTO`, `FMStrokeEffectDTO`, `FMShadowEffectDTO`, `FMStylizeEffectDTO`
(comic/watercolor/etching/woodcut), `FMMaskEffectDTO`.

For a `PATCH`, the body carries the **common** fields `enabled:Bool?` + `amount` (an envelope value —
see [Envelopes](#envelopes)) plus any of that kind's parameters (the matching `…EffectPatchDTO`);
`type` comes from the URL path, not the body. Parameters that don't apply to the effect are ignored.
The exact parameters per `type` are the effect catalog in §6.

### Envelopes

Several values vary over their lifetime and are modelled as an **envelope** — a piecewise-linear curve
of `{x,y}` points. Used by effect `amount`, layer `opacity`, layer `animationSpeed`, and audio
`volumeEnvelope`.

**The `x` axis differs by context:** it is the **fractional lifetime `0…1`** for effect `amount`, layer
`opacity`, and layer `animationSpeed`, but **absolute media time in seconds** for audio `volumeEnvelope`
(e.g. for a 200 s song, x runs `0…200`). Accordingly `x` is **never clamped**.

- **On `GET`** an envelope is **always** the full point array, e.g. `"amount":[{"x":0,"y":0},{"x":1,"y":1}]`.
- **On `PATCH`** you may send **either** a scalar shorthand (a constant envelope) **or** the full
  array — both are accepted:
  - `{"amount": 0.5}` → constant `0.5`, stored as a single point `[{x:0,y:0.5}]` (a lone point holds its
    value for all x, so a constant is unit-agnostic — no synthetic `x:1`).
  - `{"amount": [{"x":0,"y":0},{"x":0.1,"y":1},{"x":0.9,"y":1},{"x":1,"y":0}]}` → fade in/out ramp.
- Points need **not** be ordered — the server sorts them by `x`. Neither `x` nor `y` is clamped
  (audio x is in seconds; animation envelopes intentionally allow `y<0` / `y>1` for special effects).
  A non-number, non-array value → `400`. For audio `volumeEnvelope`, an empty array `[]` clears it.

**FMPointDTO** — `x:Double`, `y:Double`.

### Export

**FMExportCreateDTO** — the body of `POST /documents/{docId}/export`:
- `url:String?` — destination movie file as a `file://` URL (preferred spelling).
- `path:String?` — the same thing as a bare POSIX path (`~` is expanded). Exactly one of `url` / `path`
  is required. If it points at an existing **folder**, the filename is derived from the document name
  and the codec's extension.
- `preset:String?` — see the enum below. Omitted = the plain export defaults from Preferences.
- `range:FMExportRangeDTO?` — omitted = the whole slideshow.
- `showProgress:Bool?` — show the progress window in the app (default `true`); see above.
- `render:FMExportRenderDTO?`, `video:FMExportVideoDTO?`, `audio:FMExportAudioDTO?` — override
  individual settings of the preset. Every field is optional; omitted fields keep the preset's value.

**FMExportRangeDTO** — `slideIds:[String]?`, `chapterId:String?` (mutually exclusive; both omitted =
whole slideshow). A partial range is rendered from a clone holding just those slides, which is the fast
path for automated checks.

**FMExportRenderDTO** — `width:Int?`, `height:Int?`, `framerate:Double?`, `mode:String?`
(`letterbox`/`crop`), `pixelAspectRatio:Double?`, `fadeDuration:Double?` (automatic fade in/out at the
start and end, seconds), `reduceAliasingArtefacts:Bool?`.

**FMExportVideoDTO** — `codec:String?`, `fileFormat:String?`, `profileLevel:String?` (H264 only),
`automaticBitrate:Bool?`, `bitrate:Int?` (kbit/s, used when `automaticBitrate` is false),
`automaticKeyframeInterval:Bool?`, `keyframeInterval:Int?` (frames), `quality:Double?` (0…1, JPEG only).

**FMExportAudioDTO** — `codec:String?`, `numberOfChannels:Int?`, `samplingRate:Int?` (samples/s),
`bitrate:Int?` (kbit/s, not used by `pcm`), `bitdepth:Int?` (bits, `pcm` only).

**FMExportJobDTO** — `id:String`, `documentId:String`, `state:String`, `step:String`, `progress:Double`
(0…1 within the current step — only `renderVideo` reports fine-grained progress), `url:String` and
`path:String` (the destination, both spellings), `startedAt:Double` and `finishedAt:Double?` (seconds
since 1970), `error:String?` (set when `state` is `"failed"`), `fileSize:Int?` (bytes, once the file
exists).

### Media & errors

**FMAuthorizedFolderDTO** — `url:String` (`file://` URL of an authorized folder).
**FMAPIErrorDTO** — `error:String` (machine code), `message:String`, `details:[String:String]?`.

---

## 8. Enumerations

**FMLayerKind** (string): `"image"`, `"video"`, `"text"`, `"map"` (map not creatable in v1).

**FMEffectKind** (string): `colorCorrection`, `grayscale`, `sepia`, `blur`, `vignette`, `stroke`,
`shadow`, `comic`, `watercolor`, `etching`, `woodcut`, `mask`, `background`, `film`, `video`,
`lensDistortion`, `chromaKey`, `blendMode`, `backgroundRemoval`, `cubeLUT`. Effect-parameter enums:
`placement` (stroke) `inside`/`centered`/`outside` · `maskType` (mask) `none`/`rect`/`oval`/`image` ·
`shape` (background) `box`/`speechBubble`/`thoughtBubble`/`actionBubble` · `mode` (blendMode)
`normal`/`add`/`subtract`/`lighten`/`darken` · `quality` (backgroundRemoval) `accurate`/`balanced`/`fast`.

**Slide `continueType`** (Int) — how a slide advances:
`0` slide duration · `1` user event (click/keypress) · `2` audio marker · `3` network event.

**Transition `type`** (Int, `FMTransitionType.rawValue`):
`0` cut · `1` fade · `2` dissolve · `3` linear wipe · `4` slide · `5` push · `6` flip · `7` cube ·
`8` circular wipe · `9` angular wipe · `10` twirl · `11` zoom · `12` aperture · `13` shuffle ·
`14` topple · `15` thumb-through · `16` flash · `17` pixelate · `18` blinds · `19` broken tube ·
`20` barn door · `21` blur · `22` page curl. (`-1`/`-2` are reserved magic values.)

**Audio `track`** (Int): `0` music · `1` effects · `2` narration.

**Export `preset`** (string): `custom` · `tv` · `youtube` · `ipad` · `iphone` · `prores`. These mirror
the Share menu items and carry the same license requirements. Omit `preset` for the plain export
defaults from Preferences (only the general sharing license is then required).

**Export `render.mode`** (string): `letterbox` (fit, possibly with black bars) · `crop` (fill, possibly
cropping).

**Export `video.codec`** (string): `hevc` · `h264` · `jpeg` · `prores422` · `prores422hq` · `prores444`.
**Export `video.fileFormat`** (string): `mov` · `m4v` · `mp4` (ignored for JPEG and ProRes, which always
produce `mov`).
**Export `video.profileLevel`** (string, H264 only): `baselineProfile30`, `baselineProfile31`,
`baselineProfile41`, `baselineProfileAuto`, `mainProfile30`, `mainProfile31`, `mainProfile32`,
`mainProfile41`, `mainProfileAuto`, `highProfile40`, `highProfile41`, `highProfile42`, `highProfile50`,
`highProfile51`, `highProfile52`, `highProfileAuto`.
**Export `audio.codec`** (string): `pcm` · `aac` · `mp3` · `ac3`.

**Export job `state`** (string): `running` · `succeeded` · `failed` · `canceled`.
**Export job `step`** (string): `initialized` · `load` · `clone` · `prepare` · `renderAudio` ·
`renderVideo` · `encoding` · `move` · `cleanup` · `finished` · `canceled`.

---

## 9. Example: build a slideshow

```bash
API="http://127.0.0.1:50000/v1"
AUTH="Authorization: Bearer $(defaults read com.boinx.FotoMagico6.macOS FMWebServer-apiToken)"
JSON="Content-Type: application/json"

# Create a document (capture its id)
DOC=$(curl -s -X POST "$API/documents" -H "$AUTH" -H "$JSON" \
  -d '{"title":"My Slideshow","width":1920,"height":1080}' | python3 -c 'import sys,json;print(json.load(sys.stdin)["id"])')

# Add a slide (capture its uuid)
curl -s -o /dev/null -X POST "$API/documents/$DOC/slides" -H "$AUTH" -H "$JSON" -d '{"duration":6}'
SLIDE=$(curl -s -H "$AUTH" "$API/documents/$DOC/slides" | python3 -c 'import sys,json;print(json.load(sys.stdin)[0]["uuid"])')

# Add a title text layer, set a dissolve transition
curl -s -o /dev/null -X POST "$API/documents/$DOC/slides/$SLIDE/layers" -H "$AUTH" -H "$JSON" \
  -d '{"kind":"text","text":"Welcome"}'
curl -s -o /dev/null -X PATCH "$API/documents/$DOC/slides/$SLIDE/transition" -H "$AUTH" -H "$JSON" \
  -d '{"type":2,"duration":1.5}'

# Inspect the result
curl -s -H "$AUTH" "$API/documents/$DOC" | python3 -m json.tool
```

To add image/video layers, first authorize the containing folder
(`POST /v1/media/authorize-folder`), then reference files by `file://` URL.

---

## 10. Versioning policy

The current version is **v1**. Additive changes (new optional DTO fields, new endpoints) may land in
`v1` without notice. Breaking changes (removing/renaming fields, changing semantics) ship under a new
prefix (`/v2`); `/v1` remains available for existing clients. Always include the version in your base
URL.
