# Slideshows

A slideshow is a TikTok photo post whose photos Flaghand draws: each slide is a photo from the media library (or a
plain color) with texts on it, written as JSON. Flaghand renders every slide to a 1080-wide PNG, and those PNGs are
what the owner approves and what TikTok gets. The editor's preview runs the same renderer in the browser, so the
preview, an agent's render and the posted photos are the same pixels.

A slideshow is a `tiktok_photo` draft whose body has `slideshow` (this document) instead of `media`. Everything else
about the post (`title`, `description`, `cover_index`, `auto_add_music`, `post_mode`, the account, approval and
posting) is as in [TikTok](tiktok).

The same slides can become a LinkedIn PDF document: export with `POST …/slideshows/{id}/pdf`, then attach the answer's
`media.id` in a separate `linkedin_post` as `document: { media_id, title }` ([documents](drafts-and-media#linkedin-documents-pdf-carousels)).
No TikTok connection or approval is needed to design or export. The source remains a slideshow draft; exporting never
posts it. The PDF keeps the exact slide pixels, page order and shape (`4:5`, `1:1` or any supported aspect). Source
edits do not change an exported PDF: export again and change the LinkedIn draft's media id to use them.

## How an agent makes one

With the CLI, slide by slide (the token needs the `drafts` scope, plus `media` to upload photos; output shortened):

```
$ flaghand media upload desk.png --alt 'A planner on a desk'
med_ta5gey2zymi9kt3a  image  image/png

$ flaghand slideshows create --title 'Plan your week in 3 steps' --description 'Save this for Sunday.'
drf_m6f2nm6jcgr2ncxm  tiktok_photo  draft  "Plan your week in 3 steps"
  rev 1, 0 slides (9:16), cover 1
  render: none yet

$ flaghand slideshows add-slide drf_m6f2nm6jcgr2ncxm --media-id med_ta5gey2zymi9kt3a
  rev 2, 1 slide (9:16), cover 1
  1. sld_q5zffpsvqg  photo med_ta5gey2zymi9kt3a
  added: slide sld_q5zffpsvqg

$ flaghand slideshows add-text drf_m6f2nm6jcgr2ncxm sld_q5zffpsvqg --text 'Plan your week in 3 steps 🗓️' \
    --preset tiktok_white --size 76 --y 1500
  rev 3, 1 slide (9:16), cover 1
  1. sld_q5zffpsvqg  photo med_ta5gey2zymi9kt3a  txt_gdppsw796q "Plan your week in 3 steps 🗓️"
  added: text txt_gdppsw796q on slide sld_q5zffpsvqg

$ flaghand slideshows add-slide drf_m6f2nm6jcgr2ncxm -d '{"background":"#1f6feb","texts":[{"text":"1. Write down everything due"}]}'
  rev 4, 2 slides (9:16), cover 1
  2. sld_gbc8dbyd6k  #1f6feb  txt_4jrub9m35j "1. Write down everything due"

$ flaghand slideshows render drf_m6f2nm6jcgr2ncxm
Rendered 2 slides (2 drawn now, 0 unchanged).
hash ef8ad58300d072c373e51daf005211d92b53befe81d7f7bd1090599ae2b67816
1. sld_q5zffpsvqg  1080x1920  https://app.flaghand.com/api/v1/workspaces/ws_…/media/med_ja5pbhg93xbqbdvx/content
2. sld_gbc8dbyd6k  1080x1920  https://app.flaghand.com/api/v1/workspaces/ws_…/media/med_rmn4zrz9xufc86sv/content

$ flaghand drafts check drf_m6f2nm6jcgr2ncxm --mode approve
Ready: nothing would stop it posting exactly as previewed.
```

Look at the rendered PNGs (`flaghand media get-content med_… --out slide1.png`), change what needs changing, render
again, then request approval as for any draft ([agents](agents)). Each command answers with the slideshow, so the ids
of what it added are right there; `flaghand slideshows get drf_…` shows it again, `--json` the whole document.

Or all at once, from a whole document:

```sh
flaghand slideshows create -d @show.json
flaghand slideshows render drf_…
```

```json
{
  "title": "Plan your week in 3 steps",
  "description": "Save this for Sunday.",
  "connection_id": "con_…",
  "slideshow": {
    "aspect": "9:16",
    "slides": [
      { "image": { "media_id": "med_…", "focus": { "x": 0.5, "y": 0.3 } },
        "texts": [{ "text": "Plan your week in 3 steps 🗓️", "preset": "tiktok_white", "size": 76, "y": 1500 }] },
      { "background": "#1f6feb", "texts": [{ "text": "1. Write down everything due" }] }
    ]
  }
}
```

`POST /workspaces/{workspace}/drafts` with `kind: "tiktok_photo"` and `body.slideshow` works too. With MCP the tools are
`listSlideshowStyles`, `createSlideshow`, `getSlideshow`, `updateSlideshow`, `addSlide`, `updateSlide`, `deleteSlide`,
`reorderSlides`, `addText`, `updateText`, `deleteText` and `renderSlideshow`.

## Routes

All under `https://app.flaghand.com/api/v1`. Every change takes an optional `rev`: send the one you read and a stale one is refused with
`rev_conflict`; leave it out and the change applies to the slideshow as it is. Each change answers with the whole
slideshow (the draft, its document and its latest render), plus `slide_id` and `text_id` for what it added.

| Route | Who | Does |
|---|---|---|
| `GET /slideshows/styles` | anyone | Shapes, fonts, text styles (with what each sets), defaults and limits. |
| `GET /workspaces/{workspace}/slideshows` | `read` | Slideshow drafts, newest change first. |
| `POST /workspaces/{workspace}/slideshows` | `drafts` | Creates one: the post's fields, `connection_id`, and `slideshow` (left out: no slides yet). |
| `GET /workspaces/{workspace}/slideshows/{id}` | `read` | The draft, the document and its latest render (`render.current` says if it's of the document as it is). |
| `PATCH …/slideshows/{id}` | `drafts` | The post's fields, the shape (`aspect`), `background`, `overlay`, or a whole new `slideshow`. |
| `POST …/slideshows/{id}/slides` | `drafts` | Adds a slide (`at` a position, else last): `media_id` (or `image` with its crop), `background`, `overlay`, `texts`. |
| `PATCH …/slideshows/{id}/slides/{slide_id}` | `drafts` | Changes a slide. `media_id` swaps the photo and keeps the crop; `image` fields replace those fields; `image: null` removes the photo; `texts` replaces them all. |
| `DELETE …/slideshows/{id}/slides/{slide_id}` | `drafts` | Removes a slide. |
| `POST …/slideshows/{id}/reorder` | `drafts` | `slide_ids`: every slide once, in the new order. The cover stays on its slide. |
| `POST …/slideshows/{id}/slides/{slide_id}/texts` | `drafts` | Adds a text (`at` a position, else on top). |
| `PATCH …/slideshows/{id}/texts/{text_id}` | `drafts` | Changes a text. Fields you leave out stay; a `preset` resets the style fields you don't send. |
| `DELETE …/slideshows/{id}/texts/{text_id}` | `drafts` | Removes a text. |
| `POST …/slideshows/{id}/render` | `drafts` | Renders every slide; returns the images' URLs and the content hash. |
| `POST …/slideshows/{id}/pdf` | `drafts` | Exports the current slides to one immutable PDF, one page per slide. Returns `media`, `url`, `pages`, `render_hash` and `document_hash`; optional `rev` refuses stale source edits. CLI `slideshows export-pdf`, MCP `exportSlideshowPdf`. |

`PATCH /workspaces/{workspace}/drafts/{id}` with a whole body also works, as for any draft. The draft lists and
`GET …/drafts/{id}` show slideshows too.

## The document (version 1)

Positions and sizes are pixels of the final image, which is always 1080 wide. The schema is `Slideshow` in the
[OpenAPI spec](https://app.flaghand.com/api/v1/openapi.json) (`x-schema-version: 1`). The server fills in every field you leave out
and gives each slide and text an id, and the stored document has them all, so what you read back is exactly what's
drawn. Sending a stored document back unchanged changes nothing.

### Slideshow

| Field | Default | |
|---|---|---|
| `version` | `1` | The schema version. Another version is refused. |
| `aspect` | `"9:16"` | Every slide's shape: `9:16` 1080x1920, `4:5` 1080x1350, `1:1` 1080x1080, `16:9` 1080x608. |
| `background` | `"#000000"` | The color under every slide's photo, and of slides without one. |
| `overlay` | `null` | A tint over every slide's photo, under the texts: `{ "color", "opacity" }` (0 to 1). |
| `slides` | | Up to 35, in order. Each becomes one photo of the post. Rendering needs at least one. |

### Slide

| Field | Default | |
|---|---|---|
| `id` | assigned | `sld_…`. |
| `image` | `null` | A photo from the media library: `media_id` (JPEG, PNG or WebP), `fit` (`cover`, the default, fills the slide and crops; `contain` shows the whole photo on the background), `zoom` (1 to 10, default 1), `focus` (`{ x, y }` from 0 to 1: which part stays in view when cropped; default the center). |
| `background` | `null` | This slide's background color; `null` uses the slideshow's. |
| `overlay` | left out | This slide's tint. Left out: the slideshow's. `null`: none. |
| `texts` | `[]` | Up to 20, drawn in order (later on top). |

### Text

| Field | Default | |
|---|---|---|
| `id` | assigned | `txt_…`. |
| `text` | | Up to 2,000 characters; `\n` starts a new line. Emoji are drawn in color. |
| `preset` | `outline` | Fills in the style fields you don't send (`color`, `stroke`, `shadow`, `box`). Not stored: the document keeps the result. |
| `x`, `y` | `90`, `0` | The top left of the text's box. |
| `width` | image width − 180 | Lines wrap to it. Words longer than the width break. |
| `height` | the image's height, or `null` once you set `y` | With a height, `valign` places the text in the box; `null` is as tall as the text. So a text placed nowhere sits in the middle of the image, and one with a `y` has its top there. |
| `align` | `center` | `left`, `center`, `right`. |
| `valign` | `middle` | `top`, `middle`, `bottom`. |
| `font` | `tiktok-sans` | `tiktok-sans`, `geist`, `bebas-neue`, `cormorant-garamond`. See [fonts](fonts). |
| `weight` | the font's | 100 to 900; the nearest weight the font has is used. |
| `italic` | `false` | Only Cormorant Garamond has one. |
| `size` | `64` | Font size in pixels. |
| `line_height` | `1.2` | Times the size. |
| `letter_spacing` | `0` | Extra pixels between characters. |
| `color` | from the preset | `#rgb`, `#rrggbb` or `#rrggbbaa` (`aa` is opacity), as are all colors. |
| `stroke` | from the preset | `{ color, width }`: an outline outside the letters. `null`: none. |
| `shadow` | from the preset | `{ color, x, y, blur }`, as in CSS. `null`: none. |
| `box` | from the preset | `{ color, padding_x, padding_y, radius, mode }`: a background box. `mode` `block` is one box around all the lines; `lines` hugs each line (TikTok's own text background). `null`: none. |

### Text styles

| `preset` | Looks like | Older name |
|---|---|---|
| `outline` | White, black outline (the default) | |
| `white` | White, soft shadow | `whiteText` |
| `black` | Near black, plain | `blackText` |
| `yellow` | Yellow, black outline | `yellowText` |
| `white_box` | Dark text on a white box | `white_background` |
| `white_box_50` | Dark text on a half see-through white box | `white_50_background` |
| `black_box` | White text on a black box | `black_background` |
| `black_box_50` | White text on a half see-through black box | `black_50_background` |
| `tiktok_white` | Black text on white boxes hugging each line | |
| `tiktok_black` | White text on black boxes hugging each line | |

The older names work as `preset` too. `GET /slideshows/styles`
lists every preset's exact values.

## Rendering

`POST …/slideshows/{id}/render` draws each slide and stores it as a PNG. The answer:

```json
{
  "id": "rnd_…", "draft_id": "drf_…", "draft_rev": 4, "current": true, "drawn": 2, "renderer": "slides-2",
  "hash": "ef8ad583…", "document_hash": "…",
  "images": [{ "slide_id": "sld_…", "media_id": "med_…", "sha256": "…", "width": 1080, "height": 1920, "size": 1873455,
               "url": "https://app.flaghand.com/api/v1/workspaces/ws_…/media/med_…/content" }],
  "warnings": [{ "slide_id": "sld_…", "text_id": "txt_…", "text": "The text is taller than its box." }]
}
```

- `url` takes the same token as the API. The images are media items that the library list leaves out.
- `hash` is the render's content hash: the document, the renderer's version and every image's SHA-256.
- A slide that hasn't changed since an earlier render isn't drawn again (`drawn` counts the ones that were), and
  rendering a document that's already rendered returns that render.
- `warnings` say what didn't come out as written: characters no bundled font has (left out), text taller than its box,
  text outside the image. They don't stop it posting; fix them or not.
- A slide that can't be drawn (a missing photo, a video, a photo too big to decode) is refused with `invalid_request`
  naming the slide and saying why.

### Photos

Slides take JPEG, PNG and WebP photos (not GIFs or animated WebP). The renderer decodes only the part of a photo a slide
shows, at about the size it's shown, so a photo straight from a phone is fine: a JPEG is turned the way its EXIF
orientation says, and a see-through PNG or WebP shows the slide's background through it.

Decoding a photo can use at most 64 MB, which leaves room in a Worker. The photos phones and cameras save fit:

| Photo | Fits up to about |
|---|---|
| JPEG (standard, or "baseline": what phones and cameras save) | 100 megapixels |
| Progressive JPEG (common on the web) | 24 megapixels; 36 with ordinary (4:2:0) color, and 48 when it fills the slide |
| WebP, lossy | 48 megapixels; 24 with see-through parts |
| WebP, lossless | 12 megapixels |
| PNG | any the library takes (files up to 50 MB) |

A bigger one is refused with a sentence saying so; save it as a standard JPEG, or smaller, and add it again.

## Approving and posting

A slideshow posts the images of its current render: the one of the document as it is now.

- Check reports "The slideshow isn't rendered as it is now" until it's rendered, with the fix `render` (call
  `POST …/render`, then check again).
- The approval's snapshot holds the render (`content.render`: its `id`, `hash`, `document_hash` and `renderer`) and each
  image's SHA-256. The owner's approval covers exactly those pixels.
- Changing the slideshow in any way voids the approval, as for any draft: render again (unchanged slides aren't drawn
  again) and ask again.
- Posting sends each rendered PNG to TikTok as lossless WebP with the same pixels, checked before it's sent.

## In the browser

The editor's preview uses the same renderer, at `https://app.flaghand.com/slides/slides.js` (with its WebAssembly in `/slides/wasm/`
and its fonts in `/slides/fonts/`):

```js
import { slides } from "/slides/slides.js";
const r = await slides();
const doc = r.normalize(slideshow);
const { png, sha256, warnings } = await r.renderSlide(doc, 0, { image: (id) => fetch(`/api/v1/workspaces/${ws}/media/${id}/content`).then((x) => x.arrayBuffer()) });
const { texts } = await r.layoutSlide(doc, 0); // each text's frame, ink and lines, for handles
```

Each line from `layoutSlide` has `stops`: the x of the caret before each character and after the last, so an editor can
put a caret and a selection exactly where the renderer draws the letters.

The page's Content-Security-Policy needs `'wasm-unsafe-eval'` in `script-src` (the desk's has it).

## The editor

The desk's composer is one module, `https://app.flaghand.com/slide-editor.js` with `slide-editor.css`, built to be used anywhere a
slideshow is edited:

```js
import { slides } from "/slides/slides.js";
import { slideEditor, localStore } from "/slide-editor.js";
const editor = slideEditor({ root, renderer: await slides(), doc, store, photos });
```

- `store` saves each change and answers the whole document after it: `get()`, `update(patch)`, `addSlide(fields)`,
  `updateSlide(id, patch)`, `deleteSlide(id)`, `reorder(ids)`, `addText(slideId, fields)`, `updateText(id, fields)`,
  `deleteText(id)`, each answering `{ doc, slide_id?, text_id? }`. In the desk these are the routes above; `localStore(r,
  doc)` keeps the document in memory with the same rules.
- `photos` gives the photos to pick from: `list()`, `upload(file)`, `bytes(id)` and `src(id)`.
- `editor.set(doc)` shows a document changed elsewhere, `flush()` resolves once every change is saved, `urls()` gives
  each slide's picture, and `destroy()` stops it.

It keeps no document of its own and never draws a letter: the store's answer is what it shows, drawn by the renderer.

## The same pixels everywhere

The renderer has no step that depends on where it runs:

- glyph outlines come from the bundled fonts (opentype.js), and the overlay is drawn by resvg compiled to WebAssembly;
- JPEG and WebP photos are decoded by libjpeg-turbo and libwebp compiled to WebAssembly without SIMD
  (`tools/photo-wasm/`), PNGs by integer arithmetic, and photos are resized with fixed-point bicubic weights;
- compositing is integer arithmetic.

The tests render 13 fixtures (every text style and font, emoji, long text wrapping, photo crops, every shape, missing
characters) in Node, in Chromium and in workerd (the Workers runtime), and compare them with golden PNGs. The tolerance
is zero: every channel of every pixel must match. The PNG files themselves are compressed by the runtime's own zlib, so
the same pixels can be different bytes in a browser; the SHA-256s an approval holds are of the files the Worker stored.

A change to how slides are drawn bumps `RENDERER` (now `slides-2`), which changes every document hash, so earlier
renders are no longer current and approvals made on them don't cover the new pixels.

The renderer's third-party code and its licenses: `https://app.flaghand.com/slides/NOTICES.md`.
