This is the third lab in the managed track. The fine-tuning lab taught the model to sound like Greenbox; this one teaches the pipeline to draw like it. It is also the track’s first trip out of text: image generation, video generation, and the asynchronous invocation pattern that video forces on you. The full lab is in lab-13-weekly-creative.zip; unpack it and follow the README.
Before your first lab, do the one-time, once-per-account setup: run the zip’s preflight.sh to confirm your account is ready, then deploy the lab reaper, a standing backstop that auto-deletes any lab you forget to tear down after 24 hours. This lab runs in us-west-2, so point the reaper there too (REAP_REGIONS=us-east-1,us-west-2).
The scenario
Greenbox’s core marketing artefact changes every week, because the box does. What goes in 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, and it is the same brief every time with different nouns in it.
The weekly change already exists as data. Operations publishes a box manifest so the packing sheets, delivery notes, and subscriber emails agree on what is in the box. If the manifest is the source of truth for the box, it can be the source of truth for the picture of the box. This lab wires generation onto the end of that pipeline: four assets from one JSON file, unattended, in a consistent illustrated house style. Next week’s creative becomes a pull request against a data file.
What you’re given
CloudFormation builds an S3 bucket (the manifest goes in; the stills and video come out) and a Lambda with its role. Neither model appears in the stack, because on-demand generation is serverless: the stack is storage and permissions, nothing else.
The interesting file is manifest.json. Alongside the produce list, the farms, the recipes, and the tricky vegetable, it carries a house_style block, and that block is where two of the lab’s ideas live. The first is that the look is a field, not a habit: a style phrase, a palette phrase, and a register phrase are assembled by one function into a tail that every prompt inherits, which is what makes eight different assets read as one family. The second is that the honesty policy is code too. Its negative_text excludes photographs, people, faces, hands, and farmers by name, because Greenbox’s rule is that the real growers appear only in real photography; a generated farmer on the page about the farm that grows your carrots would mislead subscribers exactly the way generated walk-through footage of a real house misleads a buyer. Everything this pipeline produces is artwork of produce, recipes, and technique, in a register nobody would mistake for a photograph, and that register carries the whole of the policy on its own.
src/handler.py already loads the manifest, builds every prompt, derives a stable seed per asset, and handles the S3 plumbing. Two gaps are left, and they are the two calls.
Your task
Two gaps in src/handler.py, one per call shape. The stills are the synchronous side: generate_image() is one invoke_model call against IMAGE_MODEL_ID, with a JSON body carrying the assembled prompt, the house negative_prompt, the shared aspect ratio, the derived seed, and PNG as the output format. Read the response body, parse it, and decode the base64 images out of the reply. The module docstring in src/handler.py has the exact request and reply shapes.
Return what arrived, not what you asked for. images, seeds and finish_reasons come back as three lists that line up by position, and a non-null reason means the content filter withheld that image after generating it. Nothing is raised, so a pipeline that reads images[0] and skips the reasons publishes a frame the filter already rejected. There is no count field either: one call is one image, so a set of assets is a set of calls.
The clips are the asynchronous side, one job each. start_clip() calls start_async_invoke against VIDEO_MODEL_ID: the model input carries the clip’s prompt, the same aspect ratio, the fixed duration and resolution, the loop flag, and a keyframes block whose frame0 wraps the still as base64 with its media type. The output data configuration names the S3 prefix the render should land under, and the invocation ARN in the response is what you hand back. Again, the docstring spells out the exact shape.
The keyframe travels inside the request rather than as a reference to S3, which is why the handler reads the still back out of the bucket itself before starting the job. frame0 is where the clip opens; a frame1 beside it would pin the closing frame too, and leaving it out is what gives the model the five seconds to invent. loop is the one setting that changes the shape of the result rather than its content, and the technique clip sets it so a support page can play the same five seconds continuously without a visible jump. The call returns an invocation ARN and nothing else, because a render is a job: get_async_invoke reports Completed, InProgress, or Failed, and the MP4 lands under the prefix you named.
There is no multi-shot task, so the website piece is four separate renders rather than one long one. Joining them into a single film is an ffmpeg concat afterwards, and the property you buy for it is that a deflected clip costs one clip.
Deploy and prove it
Costs split the run in two, which is why the scripts do too. The stills path is cents: deploy.sh, then stills.sh for the hero and the recipe cards, then test.sh, which checks the objects landed and hands you a presigned link to the hero. The motion path bills per second of output video, so motion.sh prints the clip count, the seconds it implies, and the pricing page, then refuses to move until you type a confirmation. A clip takes a few minutes to render, and the four jobs run at once.
cd lab-13-weekly-creative
./scripts/deploy.sh # stack, manifest, handler. Cents.
./scripts/stills.sh # hero + recipe cards. Cents.
./scripts/test.sh
./scripts/motion.sh # website piece + technique clip. Gated. Real money.
./scripts/teardown.sh
Both models live in us-west-2, which is the lab’s default region for that reason rather than a preference. One prerequisite bites almost everyone: Model access for stability.stable-image-core-v1:1 and luma.ray-v2:0 are two separate console grants, and the failure usually arrives on the second one, after the stills worked and access felt sorted. If you have read about this pipeline running on Amazon Nova Canvas and Nova Reel, it did, and both are now marked Legacy: an account that was not already using them cannot call them at all, and both are withdrawn entirely on 30 September 2026, which is a fair preview of the maintenance a generation pipeline needs.
Then prove the actual point. Change the data: swap the substitution, add a recipe, rewrite the palette. Run deploy.sh and test.sh again and the artwork follows, because the prompts are built by code from manifest fields and each still derives a stable seed from the manifest’s base. A rerun of the same manifest regenerates the same stills; when a picture changes, the data changed. Next week’s creative is a manifest edit, not a design request. This is the structured-output lab inverted: there, prose went in and JSON came out; here, JSON goes in and creative comes out.
When you want the reference answer, deploy it with SRC=solution ./scripts/deploy.sh, or unfold it here:
Show the answer
resp = _bedrock.invoke_model(
modelId=IMAGE_MODEL_ID,
body=json.dumps({
"prompt": prompt,
"negative_prompt": NEGATIVE_TEXT,
"aspect_ratio": "16:9",
"seed": seed,
"output_format": "png",
}),
)
payload = json.loads(resp["body"].read())
images, reasons = payload["images"], payload["finish_reasons"]
video = _bedrock.start_async_invoke(
modelId=VIDEO_MODEL_ID,
modelInput={
"prompt": clip_text,
"aspect_ratio": "16:9",
"duration": "5s",
"resolution": "720p",
"loop": loop,
"keyframes": {"frame0": {
"type": "image",
"source": {"type": "base64",
"media_type": "image/png",
"data": base64.b64encode(keyframe_png).decode()}}},
},
outputDataConfig={"s3OutputDataConfig": {"s3Uri": output_uri}},
)
return video["invocationArn"]
The honesty line, and the keyframe contract
Two rules carry the quality of the result, and neither is a model setting.
The first is the policy in negative_text. Greenbox generates artwork of produce, recipes, and technique, and never people: the real growers appear in real photographs and real footage, because a generated farmer on the website would misrepresent something that exists. Generated media is for illustration sold as illustration, in a register nobody mistakes for a photograph. Neither of these models watermarks what it produces, so there is no provenance signal underneath to settle the question later; the register is the only thing holding the line, which is an argument for drawing rather than for a disclaimer nobody reads. The policy lives in the manifest rather than in anyone’s memory, which is what makes it survive the Friday rush.
The second is the keyframe contract, familiar from the generation-and-understanding survey: each clip opens on a still and animates away from it, so the still is the only moment of the clip you fully control. The manifest’s motion phrase (“the camera holds almost still”) keeps the invented seconds anchored to the designed one. Prompt a sweeping camera move instead and the model drifts off into scenes nobody designed, which is the walk-through failure sneaking in through the side door. The technique clip goes one step further and asks to loop, so the last frame has to meet the first: the five seconds close back onto the designed frame rather than only starting from it.
What’s worth remembering
- Structured data can drive a creative brief: the prompt template stays put, the manifest changes weekly, and the assets follow the data unattended.
- Stable Image Core is synchronous
invoke_modelwith the image inline in the response; Ray 2 isstart_async_invoke, an ARN back,get_async_invoketo follow, the file delivered to S3. - Ask both models for the same
aspect_ratioand the sizing contract is finished: a 16:9 still comes back at 2016x1152, inside the 512-to-4096 window a keyframe has to sit in. - With no style-preset field to lean on, one function that assembles the medium, the palette and the register is what holds a house style together across eight prompts.
- Exclusions belong in
negative_prompt, and a policy about what you will never generate belongs there too. - A withheld image is reported in place rather than raised:
finish_reasonslines up withimagesby position, so read the reasons instead of trusting the count. - A fixed seed buys reproducibility, not consistency, and only on the stills; the video request has no seed, so the reproducible part of a clip is the frame it opens on.
- One job per clip makes a deflected render cost one clip, and joining the set into a film is a local
ffmpegconcat rather than anything the model has to support. - Bedrock delivers video to your bucket under the caller’s own
s3:PutObject, unlike a customisation job’s service role; the permission lives on whatever identity called the model. - Generated media is for things that do not exist or artwork sold as artwork; neither of these models watermarks its output, so the register is the only thing keeping that line visible.