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

lambda/handler.py

The entire function package. Reads the job, writes each preview, reports the result. Only render_preview is project-specific.

layer/requirements.txt

The rendering dependencies, which CDK builds into a Lambda layer.

lib/custom-lambda-stack.ts

The stack: imports the shared role and the result queue URL, builds the layer, defines the function. HANDLER_KEY at the top is the one line that names the handler.

scripts/register-handler.sh

Adds the handler key to the orchestrator’s registry without overwriting the other entries.

custom-preview-demo.js

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 --profile per command rather than exporting AWS_PROFILE, and confirm the target with aws sts get-caller-identity --profile <p> before deploying.

With an SSO profile, cdk deploy failing on Unable to resolve AWS account to use while aws sts get-caller-identity still succeeds means the SSO token expired — the two read different caches. Re-run aws sso login --profile <p>.

How a job reaches your function

  1. The custom preview script returns a descriptor with lambda: "custom" and handler: "demo".

  2. OMN dispatches one preview job per descriptor to the preview queue, with processorType set to custom and the descriptor’s handler/params copied into handlerName/handlerParams.

  3. The orchestrator Lambda reads the job, sees processorType == "custom" and resolves handlerName to a deployed function name through the SSM parameter /omn/preview/custom-handlers.

  4. The orchestrator invokes that function directly, passing the job wrapped in an SQS-shaped envelope: {"Records": [<the original SQS record>]}.

  5. The function writes one preview file per entry in previews to that entry’s targetS3Key, then sends a result message to the result queue.

  6. 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

jobId

Identifies this job. Echo it back in the result message unchanged.

objectIdentity

Database identity of the asset. Also echoed back unchanged.

sourceS3Bucket / sourceS3Key

Where the source file is. Download it from there.

fileType

OMN file type of the source, e.g. JPEG. May carry trailing spaces — strip it before comparing.

previewMountBucket

The bucket every generated preview has to be written to.

previews

One entry per preview to generate — see the table below. Loop over it; there is usually exactly one entry.

handlerName

The handler key that routed the job here, demo for the template. Useful for logging.

handlerParams

The descriptor’s params string, forwarded verbatim. OMN never parses it — its format is entirely up to the handler.

Each entry of previews:

Field Meaning

targetS3Key

The exact key to write this preview to, extension included. Use it as given.

previewFileTypeName

The OMN file type the script asked for, e.g. JPEG. Determines the format to save in; the matching extension is already part of targetS3Key.

type

The OMN preview type, CUSTOM for custom previews. Echo it back.

guid

Storage GUID of this preview. Echo it back.

pageNumber

-1 for a single-page source, otherwise the 1-based page number. Echo it back.

cliArgs

Whatever the script put in cliArgs. A custom handler may interpret or ignore it; the template ignores it.

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 targetS3Key exactly as given. It contains the GUID the WebApp already reserved for this preview, so a preview written under any other key is never registered and never appears in OMN.

Write working files only below /tmp — the only writable location in a Lambda, and its size is what the function’s ephemeral storage setting configures. The template uses tempfile.TemporaryDirectory() so nothing is left behind between invocations.

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.

handler() therefore wraps the work in try/except and reports FAILED from the except branch. Keep that structure; do not let the function crash.

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 uses ImageFont.load_default(size=…) so no font file has to be shipped.

  • handler() logs the complete job with logger.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 omn-preview-custom-, for example omn-preview-custom-demo. The orchestrator is granted lambda:InvokeFunction on that name pattern only, so a name outside it cannot be invoked — and a name inside it needs *no IAM change. In the template the name is derived from HANDLER_KEY.

Execution role

The existing shared role omn-lambda-sync-role. Reference it, do not create one — see IAM.

RESULT_SQS_URL

The URL of the existing result queue omn-preview-results, published by the synchronizer deployment as the SSM parameter /omn/lambda-sync/preview.lambda.sqs.result.url. The template reads it with ssm.StringParameter.valueForStringParameter.

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

AWSLambdaBasicExecutionRole.

Read the source, write the preview

s3:GetObject and s3:PutObject on the installation’s data and preview buckets.

Read and write encrypted objects

kms:Decrypt and kms:GenerateDataKey for those buckets' keys.

Report the result

sqs:SendMessage on the result queue omn-preview-results.

Resolve the handler registry

ssm:GetParameter on /omn/preview/custom-handlers.

Invoke a custom handler

lambda:InvokeFunction on arn:aws:lambda:<region>:<account>:function:omn-preview-custom-*. Granted by name pattern, so no resource policy has to be added to the function itself.

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 No custom handler registered for handlerName=<key> even when the key is registered, because it cannot read the registry at all.

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 config-<account> bucket that holds the scripts, which the preview Lambdas cannot read at all.

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

  1. Deploy the function and add its key to /omn/preview/custom-handlers.

  2. Register a custom preview type whose script returns that handler key, and check in a matching asset — for the template a JPEG, since custom-preview-demo.js only produces a descriptor for fileType == "JPEG".

  3. 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.

  4. Check the orchestrator’s log. Unknown processorType=custom, defaulting to image handler means 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 missing ssm:GetParameter grant rather than at a missing registration. An AccessDenied on the invoke points at the missing lambda:InvokeFunction grant. See IAM.

  5. Check the function’s own log group, /aws/lambda/omn-preview-custom-demo for 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.

  6. If the preview file exists in S3 but never appears in OMN, the result message is the suspect — verify that jobId and objectIdentity are echoed unchanged and that status is COMPLETED.

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

Welcome to the AI Chat!

Write a prompt to get started...