# Task: add the FindPreset API to this project FindPreset turns a photo into a Lightroom preset (an .xmp file). Add an integration with its API to this project, following the instructions below. Full reference: https://findpreset.com/developers ## Before you write code 1. Look at this project and work out its language, framework and where photos come from. Build the integration in that stack, in server-side code only. If it is not clear what I want (which operation, where the photos come from, where the presets should go), ask me before you start. 2. The API key: read it from the environment variable FINDPRESET_API_KEY. Never hard-code it, commit it, log it or print it, and never send it to a browser or ship it inside an app. Add FINDPRESET_API_KEY with an empty value to the project's committed env example file (such as .env.example; create one if there is none), make sure the file that will hold the real value (such as .env or .env.local) is ignored by git, and tell me where to set it. Never write a real key into any file yourself. I create keys at https://findpreset.com/account/api; the API comes with a FindPreset Premium account. 3. Calls spend my account's plan and bonus credits, never a credit bought on findpreset.com to unlock one preset: 1 credit for an AI-extracted preset or a style match, 0.1 credit for an exact read (1 credit opens 10 reads; exact extraction on findpreset.com itself stays free). Never call the live API from automated tests: mock the HTTP calls. Ask me before you make any live call that spends credits. GET /api/v1/balance is free, so it is the safe way to check that the key works. ## The API Base URL: https://findpreset.com Authentication: send the header "Authorization: Bearer " (or "X-API-Key: "). Keys start with fp_live_. The API sends no CORS headers, so it cannot be called from a web page: call it from a server, a script or a background job. ### POST /api/v1/extractions Send multipart/form-data with these fields: - operation (required): exact, ai, compare or auto. - exact: reads the Lightroom settings embedded in the photo. Send "image", or "xmp" (the settings packet or the text of a .xmp sidecar, up to 1 MB). Costs 0.1 credit, charged even when the photo holds no settings. Reads come 10 to a credit: the first read spends 1 whole credit and opens 10 reads, the next 9 use them, and a read that fails goes back to them. Reads left still work at 0 credits; GET /api/v1/balance shows them as exact_reads_left. - ai: an AI-extracted preset from one photo. Send "image" and optionally "creativity" (a whole number from 0 to 100, default 50). Costs 1 credit. - compare: a style match, a preset that moves the target photo toward the reference photo. Send "reference" and "target" images, optionally "creativity". Costs 1 credit. - auto: exact when the photo carries settings, otherwise ai. Send "image", optionally "xmp" and "creativity". Costs 0.1 credit when settings are found, otherwise 1 credit. - Header Idempotency-Key: send one on every POST, 1 to 255 printable ASCII characters with no spaces. Make one key per job, save it with the job (a random UUID is fine) and reuse it only to retry that same job, so a retry after a timeout or a dropped connection returns the first request instead of running and billing again. Running the same photo again on purpose (another creativity, or after a failed or expired request) needs a new key. Do not derive the key from the photo alone: a key stays bound to its first request for 90 days, so the same photo sent again later would get the old answer, or 410 result_expired once that result is erased. The same key with a different photo, xmp, operation or creativity answers 409 idempotency_key_reused. A request that ended failed stays failed for its key, and a 410 result_expired answer to a POST means that request's result is gone: to run the photo again, use a new key. Any other refusal (an error answer other than 409 and 410) never uses up a key. - Query ?wait=true (optional): holds the connection open until an AI request finishes, up to about 270 seconds. Set the HTTP client timeout above 300 seconds when you use it. It can still answer 202, so handle 202 either way. Answers: - 200: finished. Check status (succeeded, not_found or failed), not only the HTTP code. Exact reads normally answer 200 at once, but any request can answer 202, so handle 202 everywhere. - 202: still processing. The body is the extraction with status "processing". Poll GET /api/v1/extractions/{id} with the body's id (the Location header carries the same path, relative to the base URL); Retry-After says how many seconds to wait before polling. - Answers to POST /api/v1/extractions usually carry X-Credits-Remaining (plan and bonus credits left) and X-Exact-Reads-Left, and GET /api/v1/balance always does. A replayed answer (an Idempotency-Key seen before) also carries Idempotent-Replayed: true. ### GET /api/v1/extractions/{id} Poll until status is no longer "processing": 202 while it runs, 200 once it is succeeded, not_found (an exact read found no settings) or failed. Wait the Retry-After seconds between polls; an AI request usually finishes in under a minute. Polls are free. A request is never left processing much longer than 6 minutes: by then it has finished, or it is closed as failed (error.code timeout) and its credit given back, so stop polling and report to me after 12 minutes. ### GET /api/v1/extractions/{id}/preset.xmp Downloads the preset as a file, the same content as result.preset_xmp. Add ?variant=masks for the version with local adjustments, when result.preset_with_masks_url is not null. Answers 409 result_not_ready while the request is processing. Needs the API key of the account that made the request. Free. ### GET /api/v1/balance Free. Returns whether the account has API access, its credits, the exact reads it has left and the price of each operation. ## The extraction object Fields: id, object ("extraction"), operation, method (what ran: exact, ai or compare), status (processing, succeeded, not_found or failed), credits (what it costs), created_at, completed_at, result_expires_at, result, error. result, when status is succeeded: - preset_xmp: the Lightroom preset file as a string. Write it to a file ending in .xmp; it imports in Lightroom (Presets panel, then Import Presets). - preset_xmp_with_masks: the same preset with its local adjustments, or null. - preset_url and preset_with_masks_url: where those files download, with the API key. - settings: the preset's global settings as an object (for example Exposure, Contrast). New keys may be added. - full_copy, software: exact reads only. engine_version: AI results only. error, when status is not_found or failed: { code, message }. Codes: - no_settings: An exact read found no Lightroom settings in the photo (status not_found). The read is charged (a tenth of a credit). - overloaded: The AI was busy. The credit was given back. - ai_error: The AI could not extract a preset from this photo. The credit was given back. - parse_error: The AI answer could not be read as a preset. The credit was given back. - timeout: The run did not finish in time. The credit was given back. - internal_error: Our fault. The credit (or the exact read) was given back. What to do with a request that ended without a preset (nothing was charged unless it says so): - overloaded, timeout, internal_error: retry once, after a short wait, with a new Idempotency-Key (the old key keeps its failed answer). - ai_error, parse_error: the photo is the likely cause. Do not retry it automatically; tell me. They count toward 429 failure_limit: an account with 10 or more of them in 24 hours, more than its successes, is paused for AI requests for up to 24 hours. - no_settings: the exact read was charged and found nothing. Do not rerun it as ai without asking me (that costs 1 credit); auto does both in one request. Results are erased 7 days after they finish (then 410 result_expired), so save the .xmp as soon as you get it. ## Files and limits - Each image can be up to 4 MB, and a whole request up to 4.5 MB. - AI (ai, compare, and auto when it falls back to AI) takes JPEG, PNG, WebP or TIFF, and reads photos at 1568 pixels on the long edge. Resize larger photos to 1568 pixels on the long edge before an AI request, as a JPEG at quality 90 or more, in sRGB or with its color profile kept: that is the size the AI reads, and it keeps a compare request (two images) under the size limit. - Exact reads also take HEIC and DNG. Never resize or re-encode a photo for an exact read or for auto: that usually drops the settings. For an original over 4 MB, read its settings packet (the XMP) from the original and send that as "xmp": alone with operation exact, or with auto together with a copy resized for AI as "image" (auto always needs an image: the AI reads it when the packet holds no settings). - An account runs up to 2 AI requests at a time; more answer 429 concurrency_limit. Queue AI work with at most 2 in flight. ## Errors A refused request answers { "error": { "code": "...", "message": "..." } } and is never charged. Branch on code, never on message: codes are stable, messages may change. - 401 missing_api_key: No API key was sent. - 401 invalid_api_key: The key is wrong, revoked, or from another environment. - 429 rate_limited: Too many requests with a wrong key from your address. Wait a minute. - 400 bad_request: The body is not multipart/form-data, or a field has the wrong type. - 400 missing_operation: No "operation" field. - 400 unsupported_operation: The operation is not exact, ai, compare or auto. - 400 missing_file: The operation needs an image the request does not have. - 400 bad_creativity: "creativity" is not a whole number from 0 to 100. - 400 bad_idempotency_key: The Idempotency-Key header is empty, too long or has spaces. - 400 unreadable_image: The image could not be decoded. - 402 insufficient_credits: Not enough credits for this request. Nothing was charged. - 403 plan_required: The API comes with Premium, and this account does not have it. Nothing was charged. - 404 not_found: No such request for this account, or no such endpoint. - 404 no_preset: The request finished without a preset file (it failed, or the photo had no settings to read), or has no version with local adjustments. - 405 method_not_allowed: The endpoint does not take this HTTP method. - 409 idempotency_key_reused: This Idempotency-Key was used for a different request. - 409 result_not_ready: The request is still processing, so its preset file is not ready yet. Retry after the Retry-After seconds. - 410 result_expired: The request finished more than 7 days ago; its result was erased. - 413 file_too_large: An image is over 4 MB, or "xmp" is over 1 MB. - 413 request_too_large: The whole request is over 4.5 MB. - 415 unsupported_file: A file is not an image we accept, or is a HEIC or raw file sent for AI. - 429 concurrency_limit: Your account already has its maximum number of AI requests running. - 429 failure_limit: Too many AI requests on this account failed in the last 24 hours. Nothing was charged; exact reads still work, and AI works again once those failures are more than 24 hours old. - 500 internal_error: Our fault. A request that was not opened costs nothing; to check one that was, retry with the same Idempotency-Key, which never runs or bills twice. - 503 capacity: The AI is at capacity across all customers. Retry after the Retry-After seconds. How to handle them: - Wait the Retry-After seconds, then retry the same request (same Idempotency-Key); after about 10 minutes of this, stop and show the message to the user: 429 rate_limited, 409 result_not_ready, 429 concurrency_limit, 503 capacity. Also a network error or timeout on a POST: retry it with the same Idempotency-Key. - Retry with the same Idempotency-Key (it never runs or bills twice), up to 3 times with a growing wait (for example 5, 30 and 120 seconds), then stop and show the message to the user: 500 internal_error. - Fix the request (the code, or the file it sends); do not send it again unchanged: 400 bad_request, 400 missing_operation, 400 unsupported_operation, 400 missing_file, 400 bad_creativity, 400 bad_idempotency_key, 405 method_not_allowed, 409 idempotency_key_reused, 413 file_too_large, 413 request_too_large, 415 unsupported_file. - Do not retry: stop and show the message to the user: 401 missing_api_key, 401 invalid_api_key, 400 unreadable_image, 402 insufficient_credits, 403 plan_required, 404 not_found, 404 no_preset, 410 result_expired, 429 failure_limit. - On 402 insufficient_credits, also tell me that plan credits come back when the plan renews, and that a bigger quota is a request to support@findpreset.com. ## Done means - One small module in this project's language with a function that takes a photo (and the operation), sends it, waits or polls until it finishes, and returns the .xmp text or saves it to a file, handling every error above. - Unit tests with mocked HTTP: an exact read, an AI request that answers 202 and then succeeds, an AI request that ends failed, and the 402 insufficient_credits and 429 concurrency_limit refusals. - A short README section: which env var to set, and how to call the function. - Then show me what you changed, and the one command that makes a real call, for me to run when I am ready.