Lab 13 — Generate the week’s creative from the box manifest
Scaffold: 2/5. The manifest, the prompt builders and the storage are written. You write the two calls that turn a data file into artwork.
Cost, up front. The stills path is cheap: Stability AI Stable Image Core bills per image and this generates four of them, so the whole run costs cents. The motion path is not: Luma Ray 2 bills per second of output video, and the default manifest produces four five-second clips. Both are third-party models on Amazon Bedrock, so their rates sit alongside the Amazon ones on the Amazon Bedrock pricing page. Check it before you render anything.
Nothing here leaves a meter running. A render bills for the seconds it produced and stops; there is no Provisioned Throughput to forget about. The only cost that outlives the session is S3 storage for what the jobs wrote, and
teardown.shclears that.
The scenario
Greenbox’s marketing artefact changes every week, because the box does. What goes in it depends on what came out of the ground, so the produce list, the featured farms, the suggested recipes and the one vegetable subscribers will not recognise are all different by Monday. Somebody has been briefing a designer every Friday afternoon, and the brief is the same brief every time with different nouns in it.
That weekly change already exists as data. Operations publishes a box manifest so the packing sheets, the delivery notes and the subscriber emails all agree on what is in the box. If the manifest is the source of truth for what is in the box, it can be the source of truth for the picture of what is in the box too.
This lab wires generation onto the end of that pipeline. Four assets come out of one JSON file, unattended, in a consistent illustrated house style.
The requirement
From manifest.json and nothing else:
- The box hero. One 16:9 still of this week’s box, with the produce list interpolated into the prompt by code.
- Recipe card art. One still per suggested recipe, in the same style family as the hero.
- The website motion piece. A short clip per keyframe: the box hero, then an illustrated scene of each featured farm’s crop, animated gently.
- The technique clip. Five seconds keyframed on a still of the tricky vegetable mid-prep, looping, because the support inbox fills up with “what do I do with this?” every time kohlrabi goes in the box.
Change the manifest, run it again, get next week’s assets. That is the test.
What’s provided
manifest.json— the week’s box: produce with quantities, two featured farms and what they grow, three suggested recipes with a line describing the plated dish, one tricky vegetable with its technique, and ahouse_styleblock.template.yaml— an S3 bucket for the manifest, the stills and the rendered video, plus a Lambda and its role. Neither model is a CloudFormation resource; on-demand inference is serverless, so the stack creates the storage and the permissions and nothing else.src/handler.py— manifest loading, every prompt builder, the seed derivation, and the S3 helpers. The gaps are the two calls to the generators.solution/handler.py— the reference answer.scripts/— deploy, stills, motion (gated), test, teardown.
The house style is a field, not a habit
house_style in the manifest carries the whole look:
"house_style": {
"style_phrase": "flat vector illustration poster art",
"palette": "muted garden greens, warm ochre, chalk white, ...",
"register": "clean shapes and visible drawn edges, ...",
"negative_text": "photograph, photorealistic, people, faces, hands, farmers, ...",
"motion": "the camera holds almost still and the illustration barely moves",
"seed": 733100
}
style_phrase, palette and register are assembled by style_suffix() into
one tail that every still gets, so there is exactly one place the look is
decided and eight prompts that inherit it. Stable Image Core has no style-preset
field, which means the phrasing is the control: naming the medium first and
letting the palette and register follow holds a set together better than
scattering adjectives through each prompt and hoping.
negative_text is the Greenbox honesty policy written down as code: the real
growers appear in real photographs and real footage, and nothing this pipeline
produces pretends to be either. A generated farmer on the website would mislead
subscribers exactly the way generated walk-through footage of a house misleads a
buyer, so people, faces, hands and farmers are all excluded by name. Everything
here is artwork of produce, recipes and technique, drawn in a register nobody
would mistake for a photograph. That register is the whole of the defence:
neither of these models applies the invisible watermark the Amazon generators
do, so there is no provenance signal to fall back on if an asset ever left the
site looking like a photograph. Keeping the output obviously drawn is what makes
the policy enforceable rather than aspirational.
seed is reproducibility, not consistency. Each still derives a stable seed
from that base, so a rerun of the same manifest regenerates the same pictures.
When a picture changes, the data changed. The video request has no seed field at
all, so that guarantee stops at the keyframe: the still is reproducible and the
motion is new every render.
Your task
Fill two gaps in src/handler.py. The module docstring has the exact request
and response shapes for each.
generate_image(prompt, seed)— call Stable Image Core. It is synchronous: oneinvoke_model, a base64 PNG in the response. The reply carriesimages,seedsandfinish_reasonsas three lists that line up by position, and a non-null finish reason means the filter withheld that image after generating it. Return what arrived, not what you asked for: nothing is raised, so code that readsimages[0]and ignores the reasons will publish something the filter already rejected. There is no batch field here either, so one call is one image.start_clip(prompt, keyframe_png, output_uri, loop)— call Ray 2. One job renders one clip, the keyframe goes in askeyframes.frame0inline as base64 rather than as an S3 reference, andloopdecides whether the first and last frames meet.start_async_invokehands back an invocation ARN and nothing else; the MP4 is written to the prefix you named.
Every clip is its own job, which is the reason the website piece is a set of
clips rather than one long render: a deflected clip costs one clip. Stitching
them into a single film is ffmpeg afterwards, not a model feature.
Run it
# Prerequisite: Model access enabled in us-west-2 for BOTH
# stability.stable-image-core-v1:1 and luma.ray-v2:0. They are separate grants.
./scripts/deploy.sh # stack, manifest upload, handler. Cents.
./scripts/stills.sh # assets 1 and 2. Cents.
./scripts/test.sh # runs stills and checks what landed
./scripts/motion.sh # assets 3 and 4. Gated. Real money.
./scripts/teardown.sh
Defaults are stack genai-lab-13, region us-west-2,
stability.stable-image-core-v1:1 for stills and luma.ray-v2:0 for motion.
Override with environment variables (STACK, AWS_REGION, IMAGE_MODEL_ID,
VIDEO_MODEL_ID, BUCKET_SUFFIX, MANIFEST_KEY).
Ray 2 is served in us-west-2 only, and Stable Image Core is served there too, which is why that is the default here rather than a preference. Check the model-support table by region before you point this somewhere else.
If you have read about this pipeline being built on Amazon Nova Canvas and Nova
Reel: it was, and those models are now marked Legacy, with both reaching end of
life on 30 September 2026. An account that has not used them recently cannot
call them at all, and the request fails with ResourceNotFoundException saying
the model is marked by the provider as Legacy. That is why this lab is on
Stability and Luma, and it is a fair preview of the maintenance a generation
pipeline actually needs.
motion.sh prints the clip count, the seconds of video that implies, and a link
to the pricing page, then refuses to move until you type a confirmation. It
starts every job, polls each one, and prints what landed. A clip takes a few
minutes to render, so expect it to sit there for a while.
What success looks like
Before you fill the gaps, both calls raise NotImplementedError. After:
./scripts/test.shreports four stills inassets/<week>/with bytes in them, and hands you a presigned link to the hero. The produce in the picture is the produce in the manifest.- The four stills look like they belong together, because they share a style phrase, a palette phrase and a register phrase, and none of them contains a person.
./scripts/motion.shstarts four jobs, polls them, and lists an MP4 under each clip’s own prefix undervideo/<week>/.- The motion stays close to the keyframe. It should read as a drawing that moves, not as a drawing that dissolves into a different drawing.
- The technique clip loops cleanly, because it asked to.
Then change the data. Swap the substitution, add a recipe, rewrite
house_style.palette, then run ./scripts/deploy.sh and ./scripts/test.sh
again. The artwork follows. That loop is the point of the whole lab: next
week’s creative is a pull request against a JSON file.
If it fails
AccessDeniedExceptionnaming a model — Model access forstability.stable-image-core-v1:1andluma.ray-v2:0are two separate grants in the Bedrock console, and neither is on by default. This is the single most common way this lab fails, and it usually fails on the second one, because the stills worked and it felt like access was sorted.ResourceNotFoundExceptionsaying a model is marked Legacy — you are pointing atamazon.nova-canvas-v1:0oramazon.nova-reel-v1:1throughIMAGE_MODEL_IDorVIDEO_MODEL_ID. Legacy models stay callable for accounts already using them (until end of life, 30 September 2026 for both) and are closed to everyone else, so an older copy of this lab fails here on a new account before it does anything wrong.ValidationExceptionon the still — Stable Image Core takesaspect_ratioas a ratio string, notwidthandheight, and it has no count field, so asking for several images in one call is a request it cannot parse rather than a request it ignores.ValidationExceptionon the clip — check the fixed values.durationis the string"5s"or"9s",resolutionis"540p"or"720p", and the keyframe goes underkeyframes.frame0with asourceof{"type": "base64", "media_type": "image/png", "data": ...}. Passing an S3 URI where the base64 belongs is the usual mistake carried over from other video models.- The job reaches
Failed—motion.shprintsfailureMessage, which is where a content-filter deflection, a throttle and a server-side fault read differently. Only the clip that failed needs re-rendering; the others already landed. - The render succeeds and nothing appears in S3 — the write is the part
your permissions have to cover. Bedrock puts the video into your bucket on
your behalf, under the identity that called
StartAsyncInvoke, so the Lambda’s role needss3:PutObjecteven though the Lambda never writes the MP4 itself. - A still is missing and nothing errored — the content filter withheld it.
The image is reported in place, with its reason in
finish_reasonsat the same index, and no exception is raised.stills.shreports withheld assets by name; code that assumesimages[0]is usable publishes a rejected frame here instead of telling you what happened. ValidationExceptionabout the keyframe — Ray 2 accepts keyframes between 512x512 and 4096x4096. A 16:9 still from Stable Image Core comes back at 2016x1152, which sits inside that, so asking both models for the same aspect ratio is the whole of the sizing contract. A still generated at some other ratio is fine as a picture and a letterboxed disappointment as a keyframe.- The Lambda times out — the motion run generates the farm and technique keyframes before it starts a single job. The function is set to 600 seconds and the boto3 client to a 300 second read timeout, because the SDK’s 60 second default is tighter than a run of generation calls needs.
motion.shcannot read the job status — polling runs under your own credentials, not the Lambda’s. You needbedrock:GetAsyncInvoketoo.- Teardown fails on the bucket — it holds MP4s by then.
teardown.shempties it first, so re-run it; if you wanted the video,aws s3 syncit down before you do.
Reveal the solution
SRC=solution ./scripts/deploy.sh && ./scripts/test.sh
What you just learned
- Structured data can drive a creative brief. The prompt is assembled by code from fields in a file, so the thing that changes weekly is the data and the thing that stays put is the prompt template. That is the inversion of the structured-output lab: there, prose in and JSON out; here, JSON in and creative out.
- When there is no style-preset field, the phrasing is the control. One function assembles the medium, the palette and the register into a tail every prompt inherits, which is a place to change the house style rather than eight prompts to keep in step.
- A seed buys reproducibility, not consistency. Fixing it means the same manifest regenerates the same stills, which is what makes a changed picture evidence of changed data. The video call has no seed, so the reproducible part of a clip is the frame it opens on.
- Negations belong in
negative_prompt. Writing “no people” into the prompt tends to produce people. Naming “people” innegative_promptis how you exclude them, and it is where a policy about what you will not generate belongs. - Generation is for illustration, and it should look like illustration. Artwork of produce is honest. A generated photograph of a farmer who does not exist, on a page about the farm that grows your carrots, is not. Neither of these models watermarks its output, so the register is the only thing keeping that line visible, which is an argument for drawing rather than for a disclaimer nobody reads.
- Stills are synchronous and video is not. One is
invoke_modelwith the image in the response; the other isstart_async_invoke, an ARN back,get_async_invoketo follow it, and the file delivered to S3. - A withheld image is reported, not raised.
finish_reasonslines up withimagesby position, so an unattended pipeline has to read the reasons and report the gap rather than trust that anything it got back is publishable. - The keyframe is a contract with the motion. Prompt the clip to stay near the frame you handed it and you get a drawing that moves; prompt a whole new scene and the model drifts away from the still it started on, which is how the video stops matching the artwork it was supposed to extend.
- One job per clip is a blast-radius decision. A single long render fails as
one thing. Four independent renders fail one clip at a time, and joining them
afterwards is an
ffmpegconcat rather than anything the model has to support. - Bedrock writes to your bucket as you. The
s3:PutObjectthat delivers the video is checked against the caller’s permissions, not against a service role the way a customization job works, so the grant lives on whatever identity called the model.