diff --git a/app/api/photos/page.mdx b/app/api/photos/page.mdx index e85e461..87f48d1 100644 --- a/app/api/photos/page.mdx +++ b/app/api/photos/page.mdx @@ -1,136 +1,303 @@ # Photos API -The photos API covers everything you'd want to do with the photo collection of an event: upload, batch upload, download, list, hide, delete, reorder, and trigger metadata repairs. +Photos are reachable through **two different APIs**, and picking the wrong one +is the most common reason a request comes back `401`. -For the OpenAPI spec see `/api/openapi.json` on your instance. +| | Public API (`/api/v1`) | Admin API (`/api/admin`) | +|---|---|---| +| **Auth** | API token (`Authorization: Bearer pp_live_…`) | Admin session JWT | +| **For** | Integrations, scripts, the Lightroom plugin | The admin UI | +| **Photo endpoints** | Upload, replace, list-with-marks | Everything else on this page | + +The admin API is **JWT-only** — `adminAuth` verifies a signed session token, so +an API token sent to an `/api/admin/…` route is rejected. There is currently no +API-token equivalent for delete, hide, chunked upload or dimension repair; those +are admin-UI operations. + +For the OpenAPI spec of the public API see `/api/openapi.json` on your instance. ## Authentication +### Public API + +``` +Authorization: Bearer pp_live_xxxxxxxx +``` + +Scopes are **hierarchical**: `admin` implies `write` implies `read`. On top of +the scope, the token inherits its owner's RBAC permissions — so a read needs +both the `read` scope and the `photos.view` permission. Create tokens in +**Settings → API Tokens**. See [Authentication](/api/authentication). + +### Admin API + ``` -Authorization: Bearer +Authorization: Bearer ``` -Token must have `photos.read` (for list/get) or `photos.write` (for upload/delete) scope. See [Authentication](/api/authentication) for scope details. +The JWT expires after 24 hours. Permissions are named per route below +(`photos.upload`, `photos.edit`, `photos.delete`, `photos.view`, +`photos.download`), and every event-scoped route additionally checks that the +caller owns the event. -## Endpoints +## Public API endpoints -### Upload (single batch, < 100 MB total) +### Upload a photo + +One image per request, in a multipart field named `photo` (singular). ```bash -curl -X POST "$API_URL/v1/events/$EVENT_ID/photos" \ +curl -X POST "$BASE_URL/api/v1/events/$EVENT_ID/photos" \ -H "Authorization: Bearer $TOKEN" \ - -F "photos=@/path/to/DSC_2031.jpg" \ - -F "photos=@/path/to/DSC_2032.jpg" \ - -F "category_id=ceremony" + -F "photo=@/path/to/IMG_1234.JPG" \ + -F "category_id=12" +``` + +Needs the `write` scope and the `photos.upload` permission. + +**Replacing an existing photo:** pass `replaces_photo_id` to overwrite a photo's +file while keeping its identity — the ID, the client's ratings and colour +labels, its comments and its position in the gallery all survive, and the share +link stays valid. The ID must belong to the event in the URL. + +```bash +curl -X POST "$BASE_URL/api/v1/events/$EVENT_ID/photos" \ + -H "Authorization: Bearer $TOKEN" \ + -F "photo=@/path/to/Smith_Wedding_11234.jpg" \ + -F "replaces_photo_id=1234" ``` **Constraints:** -- File types: per `general_allowed_file_types` setting (default `jpg,jpeg,png,gif,webp`). -- Max size per file: per `general_max_file_size_mb` (default 50). -- Max files per batch: per `general_max_files_per_upload` (default 500, hard cap 2000). -- Per-event `photo_cap` is enforced — uploads that would exceed the cap return 400. +- Images only — the endpoint rejects any non-`image/*` MIME type. Video goes + through the admin chunked upload. +- Max 100 MB per file. +- `category_id` is optional and must belong to this event or be global; + anything else returns `400`. + +**Response** (`201`): + +```json +{ + "id": 1234, + "filename": "1755892345_a1b2c3d4.jpg", + "path": "wedding-smith/1755892345_a1b2c3d4.jpg", + "thumbnail_path": "thumb_1755892345_a1b2c3d4.jpg", + "size_bytes": 4523894, + "category_id": 12 +} +``` + +A replace answers `200` with a different shape: + +```json +{ "replaced": true, "photo": { "id": 1234, "filename": "...", "original_filename": "...", "source_filename": "IMG_1234.JPG", "previous_filename": "..." } } +``` + +Each upload fires a `photo.uploaded` webhook. + +### List photos for an event + +Returns each photo with the proofing marks it carries — the client's colour +labels and star ratings, plus your own admin marks. This is what the +[Lightroom round-trip](/guides/lightroom-roundtrip) reads. + +```bash +curl "$BASE_URL/api/v1/events/$EVENT_ID/photos?marked_only=true&mark_source=either" \ + -H "Authorization: Bearer $TOKEN" +``` + +Requires the `read` scope and the `photos.view` permission, and is scoped to +events the token's owner may see. + +Query parameters: + +| Param | Default | Notes | +|---|---|---| +| `page` | `1` | | +| `limit` | `50` | Max 100. | +| `marked_only` | `false` | Only photos carrying a rating or colour label from `mark_source`. | +| `mark_source` | `either` | `client`, `mine`, or `either`. Drives `marked_only` and the merged `color_label` / `rating` fields. | +| `color_labels` | (omit for all) | Comma-separated client colours, e.g. `green,yellow`. | +| `my_color_labels` | (omit for all) | Same, against the token owner's own marks. | +| `min_rating` | (omit) | `0`–`5`, against the guest star average. | +| `my_min_rating` | (omit) | `1`–`5`, against your own marks. | +| `logic` | `AND` | `AND` or `OR` across the filters above. | **Response:** ```json { - "uploaded": [ - { "id": 1234, "filename": "DSC_2031.jpg", "thumbnail_url": "/thumbnails/wedding-...", "size": 4523894, "captured_at": "2026-05-12T15:23:11Z" }, - { "id": 1235, "filename": "DSC_2032.jpg", "thumbnail_url": "/thumbnails/wedding-...", "size": 4123456, "captured_at": "2026-05-12T15:23:14Z" } + "photos": [ + { + "id": 1234, + "filename": "wedding-smith_individual_1755892345.jpg", + "original_filename": "IMG_1234.JPG", + "source_filename": "IMG_1234.JPG", + "average_rating": 4.5, + "feedback_count": 2, + "color_labels": { "green": 2, "red": 1 }, + "dominant_color_label": "green", + "my_rating": 5, + "my_color_label": "green", + "color_label": "green", + "rating": 5 + } ], - "skipped": [] + "pagination": { "page": 1, "limit": 50, "total": 480, "filtered": 62, "pages": 2 } } ``` +Three fields are worth understanding before you build against this: + +- **`source_filename`** is the camera-original name, preserved even after a + photo has been replaced by an edited version. **Match on this**, not on + `original_filename` — the latter is overwritten by a replace. +- **`color_labels`** holds the per-colour tallies across all guests; + `dominant_color_label` is most-labelled-wins with ties broken green first. +- **`color_label`** and **`rating`** are the *merged* values for the requested + `mark_source` — one colour and one rating, ready to write into a catalog. + With `mark_source=either`, your own colour wins a tie and the rating takes the + higher of the two. + +`my_rating` and `my_color_label` are scoped to the token owner. Marks made by a +different admin are deliberately not visible here. + +## Admin API endpoints + +Everything below needs an **admin JWT**, not an API token, and is mounted under +`/api/admin/photos`. + ### Chunked upload (large files / batches) -For individual files over 100 MB (typical for video) or large batches, use the chunked upload flow: +For individual files over the normal upload limit (typical for video), use the +chunked flow. Every step is scoped to the event, and the permission is +`photos.upload` (`photos.view` for status, `photos.delete` to abort): ```bash # 1. Initialise -curl -X POST "$API_URL/v1/events/$EVENT_ID/uploads" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{ "filename": "video.mp4", "size": 1234567890, "mime_type": "video/mp4" }' -# → returns { upload_id, chunk_size } - -# 2. Upload chunks (parallel-safe) -for i in $(seq 0 N); do - curl -X PUT "$API_URL/v1/uploads/$UPLOAD_ID/chunks/$i" \ - -H "Authorization: Bearer $TOKEN" \ - --data-binary @chunk-$i.bin -done - -# 3. Complete -curl -X POST "$API_URL/v1/uploads/$UPLOAD_ID/complete" \ - -H "Authorization: Bearer $TOKEN" +curl -X POST "$BASE_URL/api/admin/photos/$EVENT_ID/chunked-upload/init" \ + -H "Authorization: Bearer $ADMIN_JWT" \ + -H "Content-Type: application/json" \ + -d '{ "filename": "video.mp4", "fileSize": 1234567890, "mimeType": "video/mp4" }' + +# 2. Send each chunk +curl -X POST "$BASE_URL/api/admin/photos/$EVENT_ID/chunked-upload/$UPLOAD_ID/chunk/0" \ + -H "Authorization: Bearer $ADMIN_JWT" \ + --data-binary @chunk0 + +# 3. Finish +curl -X POST "$BASE_URL/api/admin/photos/$EVENT_ID/chunked-upload/$UPLOAD_ID/complete" \ + -H "Authorization: Bearer $ADMIN_JWT" ``` -Resumable: re-uploading a chunk that already arrived is idempotent. Failed chunks can be retried individually. +Progress and cleanup: -### List photos for an event +```bash +curl "$BASE_URL/api/admin/photos/$EVENT_ID/chunked-upload/$UPLOAD_ID/status" -H "Authorization: Bearer $ADMIN_JWT" +curl -X DELETE "$BASE_URL/api/admin/photos/$EVENT_ID/chunked-upload/$UPLOAD_ID" -H "Authorization: Bearer $ADMIN_JWT" +``` + +### List photos in the admin grid ```bash -curl "$API_URL/v1/events/$EVENT_ID/photos?limit=50&offset=0" \ - -H "Authorization: Bearer $TOKEN" +curl "$BASE_URL/api/admin/photos/$EVENT_ID/photos?min_rating=4&color_label=green" \ + -H "Authorization: Bearer $ADMIN_JWT" ``` -Query parameters: +Permission: `photos.view`. This is the grid's own endpoint, with the filters the +admin UI exposes. -| Param | Default | Notes | -|---|---|---| -| `limit` | `50` | Max 200. | -| `offset` | `0` | For pagination. | -| `category_id` | (omit for all) | Filter to a single category. | -| `is_hidden` | (omit for all) | `true` / `false` | -| `sort` | `upload_date_desc` | One of `upload_date_*`, `capture_date_*`, `filename_*`, `size_*`, `rating_*` (with `_asc` or `_desc`). | +| Param | Notes | +|---|---| +| `category_id` | A numeric category id, or `individual` / `collage` / `uncategorized`. | +| `type` | Legacy type filter, kept for backwards compatibility. | +| `search` | Substring match on filename. | +| `has_likes`, `has_favorites`, `has_comments` | `true` to require each. | +| `min_rating` | Guest star average. | +| `color_label` | A single client colour. | +| `logic` | `AND` (default) or `OR` across the feedback filters. | +| `sort` / `order` | `date` (default), plus `asc` / `desc`. | + +For the mark-aware, API-token-friendly version, use the public +[list endpoint](#list-photos-for-an-event) above instead. ### Get single photo metadata ```bash -curl "$API_URL/v1/photos/$PHOTO_ID" \ - -H "Authorization: Bearer $TOKEN" +curl "$BASE_URL/api/admin/photos/$EVENT_ID/photo/$PHOTO_ID" \ + -H "Authorization: Bearer $ADMIN_JWT" ``` -Returns full metadata including width/height, EXIF capture date, feedback aggregates (likes, average rating, comment count), and download counts. +Permission: `photos.view`. Note the singular `photo` in the path — the plural +`photos` is the list route above. ### Hide / unhide a photo ```bash -curl -X PATCH "$API_URL/v1/photos/$PHOTO_ID" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{ "is_hidden": true }' +curl -X PATCH "$BASE_URL/api/admin/photos/$EVENT_ID/photos/$PHOTO_ID" \ + -H "Authorization: Bearer $ADMIN_JWT" \ + -H "Content-Type: application/json" \ + -d '{ "visibility": "hidden" }' ``` -Hidden photos are not shown to guests but remain in the database and admin grid. Useful for client-access review. +Permission: `photos.edit`. Visibility is a **string** — `visible` or `hidden`, +not a boolean. Any other value is ignored rather than rejected. + +The same route sets the category via `category_id`, which accepts a numeric +category id, `individual` / `collage`, or `null` to clear it. Setting it by hand +also clears the "automatically categorised" flag, so a later *undo automatic +categories* will not wipe your choice. + +Hidden photos stay in the database and the admin grid but are not shown to +guests. ### Delete ```bash -curl -X DELETE "$API_URL/v1/photos/$PHOTO_ID" \ - -H "Authorization: Bearer $TOKEN" +curl -X DELETE "$BASE_URL/api/admin/photos/$EVENT_ID/photos/$PHOTO_ID" \ + -H "Authorization: Bearer $ADMIN_JWT" ``` -Hard delete: removes the photo file, thumbnail, hero variant (if any), and the database row. Fires `photo.deleted` webhook. +Permission: `photos.delete`. Removes the file, its derivatives and the database +row. ### Bulk delete ```bash -curl -X POST "$API_URL/v1/photos/bulk-delete" \ - -H "Authorization: Bearer $TOKEN" \ - -d '{ "photo_ids": [1234, 1235, 1236] }' +curl -X POST "$BASE_URL/api/admin/photos/$EVENT_ID/photos/bulk-delete" \ + -H "Authorization: Bearer $ADMIN_JWT" \ + -H "Content-Type: application/json" \ + -d '{ "photoIds": [1234, 1235, 1236] }' ``` -Returns `{ deleted: 3, failed: 0 }`. Fires one `photo.deleted` webhook per photo. +Permission: `photos.delete`. The body key is `photoIds`, camelCase. Ids outside +the event in the URL are ignored, so this cannot reach another gallery's photos. ### Repair dimensions ```bash -curl -X POST "$API_URL/v1/photos/repair-dimensions" \ - -H "Authorization: Bearer $TOKEN" +curl -X POST "$BASE_URL/api/admin/photos/repair-dimensions" \ + -H "Authorization: Bearer $ADMIN_JWT" +``` + +Permission: `photos.edit`. **Instance-wide, not per event** — it scans every +`photos` row with a null `width` or `height` (skipping videos) and re-extracts +from the file. + +Returns immediately with `{ message, count }` and continues in the background; +`409` if a repair is already running. Poll it: + +```bash +curl "$BASE_URL/api/admin/photos/repair-dimensions/status" \ + -H "Authorization: Bearer $ADMIN_JWT" +``` + +```json +{ "total": 4820, "withDimensions": 4795, "withoutDimensions": 25, "isRunning": true, "lastResult": null } ``` -Scans every `photos` row with null `width`/`height` and re-extracts from the file. Returns `{ scanned, repaired, failed }`. See [System Status](/guides/admin-settings/status) for when to run this. +See [System Status](/guides/admin-settings/status) for when to run this. ## Photo + thumbnail serving @@ -146,7 +313,8 @@ These should be routed through your reverse proxy with appropriate cache headers ## Video specifics -Videos use the same upload + delete endpoints. Additional considerations: +Video goes through the **admin** upload and chunked-upload endpoints. The public +`/api/v1` upload accepts images only and rejects any other MIME type. - Formats: MP4, WebM, MOV, AVI. - Max size: 10 GB by default (cap at the proxy level too — see [Video Support](/features/video-support)). diff --git a/app/guides/_meta.js b/app/guides/_meta.js index 137692e..7fb55a6 100644 --- a/app/guides/_meta.js +++ b/app/guides/_meta.js @@ -2,6 +2,7 @@ export default { index: 'Overview', 'creating-events': 'Creating Events', 'managing-photos': 'Managing Photos', + 'lightroom-roundtrip': 'Lightroom Round-Trip', 'event-types': 'Event Types', archiving: 'Archiving Events', 'admin-settings': 'Admin Settings', diff --git a/app/guides/lightroom-roundtrip/page.mdx b/app/guides/lightroom-roundtrip/page.mdx new file mode 100644 index 0000000..23bbf46 --- /dev/null +++ b/app/guides/lightroom-roundtrip/page.mdx @@ -0,0 +1,148 @@ +# Lightroom Round-Trip + +Get the colour labels and star ratings your client set while proofing onto the +matching RAW files in Lightroom Classic — then publish the finished edits back +over the proofs, keeping every rating, comment and colour label the gallery +already collected. + +This needs the [PicPeak plugin for Lightroom Classic](https://github.com/PicPeak/plugin-lightroom). + +## The workflow + +1. **Upload the unedited camera JPGs.** Straight off the card, keeping the + camera names (`IMG_1234.JPG`). +2. **The client proofs.** They mark colour labels and stars in the gallery. You + can add your own marks in the admin photo view — those stay separate from + the client's. +3. **Import the selections into Lightroom.** Choose the event and the folder + holding your RAWs, and bring in everything or only what was marked. +4. **Edit and publish back.** Rename the files however you like. The finished + render replaces its proof in the gallery. + +Step 2 is optional. Importing *all* photos is a first-class choice, not a +fallback — the plugin is useful purely as a "get these RAWs into a collection" +tool. + +## Before you start + +Colour labels are **off by default** on new events, because a colour bar +appearing unannounced mid-proofing would be a visible change to a live gallery. +Turn them on per event in the feedback settings, or globally with +`event_default_allow_color_labels` so new events inherit it. + +PicPeak uses Lightroom's own five colours — red, yellow, green, blue, purple — +specifically so a client's pick needs no remapping on the way into your catalog. + +## Importing selections + +In Lightroom Classic: **Library → Plug-in Extras → Import selections from +PicPeak…** + +| Setting | What it does | +|---|---| +| **Event** | Which gallery to read marks from | +| **RAW folder** | Where your RAW files live. Subfolders optional | +| **Import** | All photos, only marked ones, or only specific colours/ratings | +| **Marks from** | Client picks, your own marks, or either | +| **Apply** | Colour labels, star ratings, or both | +| **If it differs** | What happens when the photo already has a value — see below | +| **Collection** | Name for the collection the matches land in | + +Photos already in your catalog are marked in place. Anything not yet imported +is added, if you leave that option ticked. + +### Merging, not overwriting + +Re-running an import, or importing onto photos you already triaged in +Lightroom, must not silently destroy your work. Pick what happens when a photo +already carries a **different** colour or rating: + +| Mode | Behaviour | +|---|---| +| **Fill empty only** *(default)* | Never touch a photo that already has a value | +| **PicPeak wins** | Overwrite unconditionally | +| **Lightroom wins** | Write only where Lightroom is empty | +| **Highest priority wins** | Green → yellow → red → blue → purple wins; for stars, the higher count wins | + +Green ranks first because in a proofing workflow green means *first choice* — +the pick that has to survive a disagreement. The same order breaks ties when +several guests labelled one photo differently. + +Every run reports how many conflicts it saw, so a lossy setting is never +silent. A colour label PicPeak doesn't know — one you defined yourself in +Lightroom — is treated as lowest priority rather than discarded. + +## Publishing the edits back + +Edit as usual and re-publish. The render replaces its proof: the photo keeps +its ID, the client's ratings and colour labels, its comments, and its position +in the gallery. The share link stays valid. + +**Renaming is fine.** During the import, the plugin stamps the PicPeak photo ID +onto the catalog photo. That ID travels with the photo, so the filename is free +to change — it is the ID, not the name, that carries the edit home. + +## Multi-camera shoots + +Two bodies both produce `IMG_1234.JPG`, and PicPeak cannot tell them apart. +Rename on ingest, **before uploading**, so the camera index becomes part of the +number: + +``` +cam11234.jpg ← camera 1, frame 1234 +cam21234.jpg ← camera 2, frame 1234 +``` + +Matching reads the **longest** trailing run of digits, so `11234` and `21234` +stay distinct where a bare `1234` would collide. A separator is not needed — +swallowing the camera index into the number is what makes the run unique. + +Keep the camera index single-digit (`cam1`–`cam9`). With a four-digit camera +counter that gives a consistent five-digit run. + +### If you use the number fallback + +Matching normally uses the full filename. There is an optional *"Also match by +trailing file number"* setting for RAWs that were renamed before proofing, and +a matching `match_mode` on uploads for renders whose RAW never went through the +import. + +If you rely on either, your delivery name has to keep the **whole** number: + +``` +Smith_Wedding_11234.jpg ✅ keeps the full run +Smith_Wedding_1234.jpg ❌ truncated — both cameras collide again +``` + +Files that do share a number are skipped and reported, never guessed at. A +colour label on the wrong photo is worse than a missing one, because nothing +about it looks wrong afterwards. + +## Exporting without the plugin + +You don't need Lightroom to get selections out. The admin photo view exports +filtered selections as: + +- **XMP sidecars** — `xmp:Label` and `xmp:Rating` per photo, read by Lightroom, + Bridge and Capture One. Note that Lightroom only reads a standalone `.xmp` + sidecar for RAW/DNG masters, and you have to trigger + *Metadata → Read Metadata from File* yourself. +- **CSV / JSON** — every mark including the per-colour tallies. +- **Plain text file lists** — for Capture One and other tools. + +Exports can carry either the client's verdict or your own marks. + +## Troubleshooting + +**Nothing matched.** Check that the RAW stems match the uploaded names — +`IMG_1234.CR3` matches a proof uploaded as `IMG_1234.JPG`. If the RAWs were +renamed after upload, enable the number fallback. + +**"N local files share the number".** Two RAWs have the same trailing digits. +Use the camera-prefix scheme above, or match on full filenames instead. + +**Labels didn't change.** The default conflict mode never overwrites an +existing value. Switch to *PicPeak wins* or *Highest priority wins*. + +**Colours are missing entirely.** Colour labels are probably disabled on that +event — see [Before you start](#before-you-start).