Photoshop API — Multi-Layer Composition
The orchestration pattern for production-grade campaign assembly. When the asset pipeline involves Firefly-generated content + template PSDs + multiple stages of editing + multiple output aspect ratios, you need a stateful workflow engine that coordinates many Photoshop API + Firefly calls in the right order.
This is the architectural pattern that scales a generative pipeline from a single hand-assembled asset to high-volume template-driven campaign runs.
When to Use This Skill
Use this skill when:
- A single asset request requires more than one Photoshop API call
- Firefly-generated content (from
firefly-generate-image-v3-asyncorfirefly-expand-fill) feeds into a PSD pipeline - One source must produce many aspect-ratio variants
- The workflow has decision points (e.g., "if image has people, use template A, else template B")
- The user mentions "state machine", "step functions", "workflow", "pipeline", "orchestrator", "campaign assembler"
Do NOT use this skill when:
- Single-step smart-object replacement is enough — use
photoshop-api-actionsdirectly - The pipeline is purely Firefly generation without any compositing — use
firefly-generate-image-v3-async+firefly-services-rate-limits
The 10-20 Function State Machine
A production template-driven compositing pipeline runs as a state machine — typically 10-20 single-responsibility functions. Each function does one thing and one thing only — composable, independently retryable, observable.
START
│
├── validate-request (input schema, asset URLs reachable)
├── load-campaign-config (template id, brand assets, copy)
├── load-template-manifest (PSD layer structure, cached)
├── route-by-content-type (people present? logo only? etc.)
│
├── detect-subject (Photoshop API: POST /v2/remove-background, mode: mask)
├── crop-and-fit (Photoshop API: productCrop / applyAutoCrop)
├── upload-source-to-firefly (storage ref)
├── generate-expanded-background (firefly-expand: hero image → full bg)
├── poll-firefly-job (async, with retry)
├── download-firefly-output (re-host in our bucket)
│
├── composite-psd (Photoshop API: smart-object replacement)
├── apply-brand-action (Photoshop API: actions/play .atn)
├── overlay-text-layer (Photoshop API: text-layer replacement)
├── poll-psd-job (async)
├── download-rendered-output (jpeg/png)
│
├── render-variant-aspects (fan-out for each aspect ratio)
│ ├── render-1920x1080 (Photoshop API: image/jpeg output)
│ ├── render-1080x1920 (idem)
│ └── render-1080x1080 (idem)
│
├── persist-final-assets (S3 multipart upload, DynamoDB record)
├── notify-customer (webhook or SNS)
└── audit-log (audit event ingest)
END
Note on detect-subject: the current subject-mask endpoint is POST https://image.adobe.io/v2/remove-background with mode: "mask" (it supersedes the V1 sensei/mask family). Unlike the Photoshop document APIs, V2 remove-background hosts the result itself — the grayscale mask arrives as an Adobe pre-signed destination.url in the job status, so this function does not supply an output bucket.
Each function:
- Is idempotent (re-running with the same input produces the same output)
- Returns a typed result that the next function consumes
- Logs structured events for observability
- Throws on terminal failure (no swallowed errors)
This composes into a state machine. AWS Step Functions, GCP Workflows, or Azure Durable Functions all support this shape.
Step 1 — Pick the Orchestration Engine
| Cloud | Engine | When |
|---|---|---|
| AWS | Step Functions (Standard) | Long-running workflows, full audit trail |
| AWS | Step Functions (Express) | High-volume short-running ones |
| GCP | Cloud Workflows | Equivalent to Step Functions |
| Azure | Durable Functions | Equivalent, function-fused |
| Self-host | Temporal | Multi-cloud, more flexible, more ops burden |
For a typical 10-20 function pipeline, AWS Step Functions (Standard) is the right default. Express tier is for high-volume short jobs (think tens of thousands per day, sub-5-minute total duration) — not the typical asset pipeline.
Step 2 — Function Boundaries
Each function should map to exactly one of:
| Function type | Example |
|---|---|
| External API call | Submit Firefly generate, poll Firefly status |
| Pure transformation | Calculate target dimensions from input |
| I/O | Read/write S3, query DynamoDB |
| Decision | Route to template A or B based on content tags |
| Side-effect | Send webhook, write audit log |
Anti-pattern: kitchen-sink Lambdas. A Lambda that does "submit job AND poll AND download AND persist" is hard to retry, hard to observe, and hard to evolve. Decompose.
Step 3 — Composition Workflow (in Step Functions JSON)
Simplified excerpt from a production state machine:
{
"Comment": "Campaign asset assembly pipeline",
"StartAt": "ValidateRequest",
"States": {
"ValidateRequest": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:validate-request",
"Next": "LoadTemplate"
},
"LoadTemplate": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:load-template-manifest",
"Next": "DetectSubject"
},
"DetectSubject": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:photoshop-detect-subject",
"ResultPath": "$.detectedSubject",
"Retry": [{
"ErrorEquals": ["States.TaskFailed"],
"IntervalSeconds": 2,
"MaxAttempts": 3,
"BackoffRate": 2.0
}],
"Next": "GenerateExpandedBackground"
},
"GenerateExpandedBackground": {
"Type": "Task",
"Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken",
"Parameters": {
"FunctionName": "firefly-expand-submit",
"Payload": {
"input.$": "$",
"taskToken.$": "$$.Task.Token"
}
},
"Next": "CompositePsd",
"TimeoutSeconds": 300
},
"CompositePsd": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:photoshop-composite",
"Next": "RenderAspectVariants"
},
"RenderAspectVariants": {
"Type": "Map",
"ItemsPath": "$.targetAspects",
"Iterator": {
"StartAt": "RenderOne",
"States": {
"RenderOne": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:photoshop-render",
"End": true
}
}
},
"Next": "Persist"
},
"Persist": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:persist-and-notify",
"End": true
}
}
}
Key Step Functions patterns:
| Pattern | Use |
|---|---|
waitForTaskToken |
Long-running async jobs (Firefly/Photoshop) — pause the state machine, resume on completion via SendTaskSuccess |
Map |
Fan-out parallel work (multi-aspect rendering) |
Retry per state |
Exponential backoff on transient failures |
Catch per state |
Route terminal failures to a DLQ-like state |
Step 4 — The Async Submit/Poll Pattern in Step Functions
Firefly and Photoshop API operations are async. The standard pattern is waitForTaskToken:
- Step Functions submits the job and passes
taskTokento the Lambda - Lambda submits to Firefly/Photoshop, stores
{ jobId, taskToken }in DynamoDB - Lambda returns immediately; Step Functions pauses
- A separate poller Lambda (scheduled every 5s) reads pending jobs from DynamoDB
- For each job: poll the Adobe statusUrl; on completion, call
SendTaskSuccess(taskToken, result) - Step Functions resumes with the result
This pattern decouples submission from polling and avoids burning Step Functions cost on idle waits.
Alternative: webhook callback (when available)
Illustrative — verify against current Adobe docs. The
notify/webhookUrlfield below is not part of the published Firefly, Photoshop, or Lightroom request schemas or the official SDKs at time of writing — no bundled OpenAPI spec documents a request-body webhook field. Treat this section as a design pattern, not a documented contract: confirm field names against the current Adobe Firefly Services documentation before relying on it. If your account does not expose webhook callbacks, thewaitForTaskTokenpolling pattern above is the default.
If the endpoint supports webhooks (notify.webhookUrl), use those instead of polling. The pattern:
- Step Functions submits the job with
notify.webhookUrl = "<api gateway URL>" - Adobe calls the webhook on completion
- API Gateway → Lambda →
SendTaskSuccess(taskToken, result) - Step Functions resumes
Webhooks eliminate the polling Lambda entirely. Use them when the endpoint supports them; otherwise keep the polling pattern as the default.
Step 5 — Multi-Aspect Rendering (Map State)
A single composition typically needs to render at multiple aspect ratios. The Step Functions Map state handles this:
"RenderAspectVariants": {
"Type": "Map",
"ItemsPath": "$.targetAspects",
"MaxConcurrency": 5,
"Iterator": {
"StartAt": "RenderOne",
"States": {
"RenderOne": {
"Type": "Task",
"Resource": "arn:aws:lambda:...:photoshop-render",
"Parameters": {
"compositionUrl.$": "$.compositionUrl",
"aspect.$": "$$.Map.Item.Value"
},
"End": true
}
}
}
}
MaxConcurrency: 5 is the rate-limit guard — even though Step Functions can fan-out to thousands, Photoshop API rate limits cap actual throughput. Tune to your provisioned RPM.
Step 6 — Observability
For a 10-20 function pipeline, structured logging is non-negotiable. Every function should emit:
| Field | Source |
|---|---|
traceId |
Step Functions execution ARN |
state |
Step Functions state name |
customer |
From input payload |
campaignId |
From input payload |
apiCall.endpoint |
Which Adobe endpoint was hit |
apiCall.duration_ms |
Wall-clock |
apiCall.status |
HTTP status |
apiCall.retryAfter |
Retry-After header on any 429 — the documented rate-limit signal. If X-RateLimit-* headers happen to be present, log them too, but treat them as best-effort |
outcome |
succeeded / failed / retried |
Dashboards to build:
- Pipeline success rate per template per customer
- p50 / p95 end-to-end duration
- Per-state failure rates (which step breaks most often)
429rate andRetry-Afterdistribution per credential (best-effort:X-RateLimit-*header values when present)
Validate
A composition pipeline is production-ready when:
- Each function is single-responsibility and idempotent
- Async submissions use
waitForTaskTokenor webhook callbacks — no inline polling in Lambdas - Retry policies are explicit per state, not implicit
- Multi-aspect rendering uses
MapwithMaxConcurrencymatched to provisioned RPM - Every state machine execution emits structured logs with
traceId - Failed runs land in a separate "review" S3 prefix with the full state-machine snapshot
Troubleshooting & Edge Cases
- State machine stuck "running" for hours:
waitForTaskTokennever receivedSendTaskSuccess. The poller Lambda is broken, or the webhook was never reached. AddTimeoutSecondsper state and explicit failure transitions. - Half-rendered outputs in S3: A function crashed mid-write. Use multipart upload + atomic rename pattern (write to
s3://bucket/_temp/job-id/first, then rename to final path on success). - Concurrent jobs interfere: Two jobs reading/writing the same key. Use
jobIdin every storage key. - Step Functions cost too high: Standard tier bills per state transition. Squash trivial states (single-line transforms) into the preceding Task.
- Templates drift between environments: Manifest cache holds stale layer structure. Re-fetch on every template update; tag templates with content-hash.
Chaining with Other Skills
photoshop-api-actions— Each Task in the state machine is one of these callsfirefly-generate-image-v3-async— Generates inputs to the composition stagefirefly-expand-fill— Provides expanded backgrounds and patched regionsfirefly-services-storage-refs— Storage URL generation at each stagefirefly-services-rate-limits—MaxConcurrencycalibration