Project-specific Custom Preview Lambda
A custom preview script can route a preview to a project-specific Lambda instead of one of
the standard preview Lambdas, by returning lambda: "custom" together with a handler key.
This page describes the contract such a function has to implement, using the deployable
template custom-lambda-cdk/ from the
omn-lambda-sync repository as the
running example.
For the script side — how a descriptor selects the handler and what handler and params
mean — see
Custom Preview Generation with the Lambda Synchronizer.
|
A project creates only the Lambda function. Everything around it already exists in the target account and is shared with the standard preview Lambdas: the execution role, the queues, the buckets and the orchestrator that dispatches the jobs. Do not create a role, a queue or a bucket, and do not deploy the synchronizer’s own CDK stack — it owns that infrastructure. Your function attaches to what is already there. |
When a custom Lambda is needed
Most custom previews do not need one. The standard preview Lambdas already run ImageMagick
and ffmpeg, and a descriptor can hand them arbitrary arguments through cliArgs, so anything
expressible as a tool invocation on the source file belongs there: resizing, cropping,
rotating, changing quality, colour conversion, format changes.
A project-specific Lambda is the right answer when the preview needs something the tool call cannot express, for example:
-
an additional input — compositing a logo, a frame or a background that has to be fetched from somewhere;
-
data-dependent rendering — placing a text from the asset’s metadata onto the image;
-
a different library or service than the ones the standard images ship;
-
multi-step logic that would be a pipeline rather than one command.
Before writing a Lambda, check whether cliArgs plus toolParams gets you there. A
custom Lambda is a deployable artefact with its own build and release cycle; cliArgs is a
line in a script.
|
The demo handler
custom-lambda-cdk/ in the
omn-lambda-sync repository is a
complete, deployable handler. It
implements the whole contract and is registered under the handler key demo; the only
project-specific part is the rendering.
| File | Purpose |
|---|---|
|
The entire function package. Reads the job, writes each preview, reports the result. Only |
|
The rendering dependencies, which CDK builds into a Lambda layer. |
|
The stack: imports the shared role and the result queue URL, builds the layer, defines the function. |
|
Adds the handler key to the orchestrator’s registry without overwriting the other entries. |
|
The OMN-side custom preview script that routes JPEG sources to this handler. |
What it renders: the source is scaled to fit width x height (aspect ratio preserved, never
upscaled) and text is drawn into the bottom-right corner on a translucent plate. All three
values come from handlerParams, which custom-preview-demo.js sets to
text=OMN 7.0 demo;width=600;height=600.
That is deliberately something the standard Lambdas could not produce, so a preview appearing in OMN proves the whole custom route works — dispatch, invocation, S3 write, result registration — before any project code exists.
Deploying it:
npm ci
npx cdk bootstrap --profile <p> # once per account/region
npx cdk deploy --profile <p> # builds the layer, creates the function
./scripts/register-handler.sh --profile <p> demo # registers it with the orchestrator
The stack is OmnCustomPreviewDemoStack and the function it creates is
omn-preview-custom-demo.
|
Pass With an SSO profile, |
How a job reaches your function
-
The custom preview script returns a descriptor with
lambda: "custom"andhandler: "demo". -
OMN dispatches one preview job per descriptor to the preview queue, with
processorTypeset tocustomand the descriptor’shandler/paramscopied intohandlerName/handlerParams. -
The orchestrator Lambda reads the job, sees
processorType == "custom"and resolveshandlerNameto a deployed function name through the SSM parameter/omn/preview/custom-handlers. -
The orchestrator invokes that function directly, passing the job wrapped in an SQS-shaped envelope:
{"Records": [<the original SQS record>]}. -
The function writes one preview file per entry in
previewsto that entry’stargetS3Key, then sends a result message to the result queue. -
The WebApp consumes the result and registers the previews, exactly as it does for a standard preview.
| The orchestrator’s handler registry is cached and refreshed every 60 seconds, so adding a handler to the SSM parameter takes effect within a minute without redeploying the orchestrator. |
So a function has exactly three responsibilities: read the job, write each preview to the key
the job specifies, and report the outcome. handler.py splits them across handler(),
process_job() and send_result().
1. The job payload
The payload is the same job the standard preview Lambdas receive. It arrives as the JSON body
of a single SQS-shaped record, which handler() unwraps:
job = json.loads(event["Records"][0]["body"])
| Field | Meaning |
|---|---|
|
Identifies this job. Echo it back in the result message unchanged. |
|
Database identity of the asset. Also echoed back unchanged. |
|
Where the source file is. Download it from there. |
|
OMN file type of the source, e.g. |
|
The bucket every generated preview has to be written to. |
|
One entry per preview to generate — see the table below. Loop over it; there is usually exactly one entry. |
|
The handler key that routed the job here, |
|
The descriptor’s |
Each entry of previews:
| Field | Meaning |
|---|---|
|
The exact key to write this preview to, extension included. Use it as given. |
|
The OMN file type the script asked for, e.g. |
|
The OMN preview type, |
|
Storage GUID of this preview. Echo it back. |
|
|
|
Whatever the script put in |
A job as the demo handler receives it:
{
"jobId": "custom-4711-1",
"objectIdentity": 4711,
"sourceS3Bucket": "data-<account>",
"sourceS3Key": "MAM/Asset-Data/ab/cd/product.jpg",
"fileType": "JPEG",
"previewMountBucket": "previews-<account>",
"processorType": "custom",
"handlerName": "demo",
"handlerParams": "text=OMN 7.0 demo;width=600;height=600",
"previews": [
{
"type": "CUSTOM",
"guid": "F00072DF475985274B02E253323D",
"targetS3Key": "omn/previews/custom/ab/cd/ef/gh/ij/kl/Demo.jpeg",
"previewFileTypeName": "JPEG",
"pageNumber": -1
}
]
}
The job also carries the fields the standard Lambdas use to reproduce OMN’s tool
invocation — iccProfileMode, sourceFileModifier, sourcePreArgs, renderDpi, maxPages,
dimensionLimit. A custom handler is free to ignore all of them, as the template does.
|
handlerParams is opaque to OMN, so its format is the handler’s own choice. The template’s
parse_params() reads key=value;key=value and never raises — a malformed params string must
not turn into a failed preview.
2. Writing the preview
For every entry in previews, write one object to targetS3Key in the bucket named by
previewMountBucket. In process_job() that is:
s3.download_file(job["sourceS3Bucket"], job["sourceS3Key"], source_path)
render_preview(source_path, out_path, preview.get("previewFileTypeName", "JPEG"), params, job)
s3.upload_file(out_path, job["previewMountBucket"], preview["targetS3Key"])
|
Use Write working files only below |
3. The result message
Send exactly one message per job to the result queue, whose URL the function receives in the
RESULT_SQS_URL environment variable. send_result() builds it:
{
"jobId": "custom-4711-1",
"objectIdentity": 4711,
"status": "COMPLETED",
"previews": [ { "type": "CUSTOM", "guid": "F00072DF475985274B02E253323D",
"targetS3Key": "omn/previews/custom/ab/cd/ef/gh/ij/kl/Demo.jpeg",
"previewFileTypeName": "JPEG", "pageNumber": -1 } ]
}
jobId and objectIdentity have to be the values from the job — the WebApp matches the result
to the dispatched job by them — and each preview entry repeats type, guid, targetS3Key,
previewFileTypeName and pageNumber. On failure, send status FAILED with an
errorMessage and an empty previews list.
|
Always send a result, including on failure. A job that never reports back leaves nothing for the WebApp to register and produces no error anywhere on the OMN side — the preview simply never appears, which is the hardest failure mode to diagnose.
|
Replacing the rendering
Everything project-specific sits in one function:
def render_preview(source_path: str, out_path: str, output_file_type: str,
params: dict[str, str], job: dict[str, Any]) -> None:
"""Produce the preview file at out_path from the source file at source_path."""
output_file_type is the OMN file type the script asked for — save in that format, and do not
append an extension, because the matching one is already part of the target key. The template’s
pil_format() maps those codes to Pillow format names, including OMN’s PNGf for PNG, and
falls back to JPEG for an unknown code rather than failing the preview. params is the parsed
handlerParams; job is passed through for rendering that depends on the source or its
metadata.
Everything else in handler.py is the contract and can be taken over unchanged. Two demo-only
details are worth removing once the handler does real work:
-
watermark_font()exists only for the watermark text. It usesImageFont.load_default(size=…)so no font file has to be shipped. -
handler()logs the complete job withlogger.info("[job] received: %s", json.dumps(job)). That is useful while learning the contract but the payload can be large.
An additional input object is fetched from S3 the same way as the source, for example a logo to composite instead of the text:
logo_path = os.path.join(os.path.dirname(out_path), "logo")
s3.download_file(job["sourceS3Bucket"], params["logo"], logo_path)
Which buckets that may read from is the one real constraint — see IAM.
The three wiring requirements
Everything else about the function is a free choice; these three are not. The template already satisfies all of them.
| Requirement | Value |
|---|---|
Function name |
Must match |
Execution role |
The existing shared role |
|
The URL of the existing result queue |
CDK is not a requirement — any tool works as long as those three hold.
Registering the handler key
The orchestrator resolves handler keys through the SSM parameter
/omn/preview/custom-handlers, a single JSON object holding every key of the installation:
{
"demo" : "omn-preview-custom-demo",
"watermark" : "omn-preview-custom-watermark"
}
The parameter holds all handler keys. Overwriting it with only your own key
unregisters every other custom preview in the installation. Use the template’s
scripts/register-handler.sh, which reads the current value, merges and writes it back, and
refuses to write at all if it cannot read the current value first.
|
IAM
There is no role to create and no policy to write: all synchronizer Lambdas share one
pre-provisioned execution role, omn-lambda-sync-role, and a function that references it
inherits what a preview handler needs. What that role has to allow:
| Purpose | Needed on the role |
|---|---|
Logging |
|
Read the source, write the preview |
|
Read and write encrypted objects |
|
Report the result |
|
Resolve the handler registry |
|
Invoke a custom handler |
|
|
The first four are present in every installation, because the standard preview Lambdas need
them. The last two exist only for custom handlers and are missing in installations whose
infrastructure predates the feature — verify them before testing a new handler. The symptom of
the missing SSM grant is misleading: the orchestrator logs
The S3 grant covers only the installation’s data and preview buckets, so any additional
object the handler reads has to live in one of them — a logo to composite belongs in the data
bucket, not in the Widening the role is not something a project can do on its own: it is shared by the whole preview pipeline, and which stack owns it differs per installation. Raise it with the team that owns the synchronizer deployment. |
Verifying a new handler
-
Deploy the function and add its key to
/omn/preview/custom-handlers. -
Register a custom preview type whose script returns that
handlerkey, and check in a matching asset — for the template a JPEG, sincecustom-preview-demo.jsonly produces a descriptor forfileType == "JPEG". -
Follow the OMN-side log category
com.meylemueller.isy.sync.lambda.CustomPreviewScript.<identifier>: the[output]line has to list the descriptor. If it does not, the problem is in the script, not in the Lambda. -
Check the orchestrator’s log.
Unknown processorType=custom, defaulting to image handlermeans the deployed orchestrator predates the custom-handler route.No custom handler registered for handlerName=<key>means the key could not be resolved — the message lists the keys it does know, and an empty list points at the missingssm:GetParametergrant rather than at a missing registration. AnAccessDeniedon the invoke points at the missinglambda:InvokeFunctiongrant. See IAM. -
Check the function’s own log group,
/aws/lambda/omn-preview-custom-demofor the template. A successful run traces the whole contract:[job] jobId=… handler=demo fileType='JPEG' previews=1 [render] text='OMN 7.0 demo' box=600x600 outputType=JPEG sourceBytes=46466 [render] drew 'OMN 7.0 demo' at (247,322) -> saved JPEG 600x375, 16250 bytes [job] uploaded CUSTOM (16250 bytes) -> s3://previews-<account>/omn/previews/custom/… [result] sent to omn-preview-results: {"jobId":…,"status":"COMPLETED",…}A missing log group means the function was never invoked, which puts the fault upstream — at step 3 or 4, not in the handler.
-
If the preview file exists in S3 but never appears in OMN, the result message is the suspect — verify that
jobIdandobjectIdentityare echoed unchanged and thatstatusisCOMPLETED.
A second handler makes a useful control when a new one stays silent: deploy the template unchanged under its own key and point a script at that key. Its rendering is known to work, so whatever fails then is registration, dispatch or result handling rather than the new code. What an installation already offers is in the registry:
aws ssm get-parameter --profile <p> --name /omn/preview/custom-handlers \
--query Parameter.Value --output text