Skip to content

Notes2026.08.08

I put fifteen teammates on a movie poster. Their faces had to stay theirs.

A 60 by 90 cm poster of my team as heroes and villains, built on my Mac with local models, a chain of scripts and several Claude Code sessions. No face left the machine.

Status
Published
Date
2026.08.08

For my team’s office wall I made a movie poster with fifteen teammates as the characters, plus one more face hidden in the lens of a villain’s helmet. It’s 6000 by 9000 pixels, for a 60 by 90 cm print. I started on September 25, 2026, and version six, the final one, was done on October 20.

The rule was that every face stays its owner’s. The bone structure, the beard and the glasses belong to the teammate, and the character comes from the hair, the costume, the props and the light. Everything ran locally on my Mac, so no one’s face went to a cloud service. What came out is one editable master file, files for the print shop and a behind-the-scenes video.

One file that stays editable

The master is a Compositor file. The source PSD was 16-bit and the importer only takes 8-bit, so I rebuilt it layer by layer, and the first rebuild matched the PSD with a mean difference of 3.46 out of 255.

A .comp is a folder with a manifest.json and one PNG per layer, and the app reloads it in about 0.3 seconds. That makes it easy to drive from scripts, with one catch: if a single rule in the manifest is broken, the app ignores the whole file without saying why. So every write happens in the same order. The PNGs go in first, then a temporary manifest, then an atomic mv puts it in place, and then the file gets validated.

The layout is data too, in layouts/*.json, and layout.CURRENT says which one is live.

Models on my own machine

The engine is ComfyUI, running headless. It listens on 127.0.0.1 only, with API nodes off and Hugging Face set to offline, and every model is checked by its SHA-256.

ModelWhat it did
Flux.2 Klein 4B, fp8Most of the work
Klein 9B, GGUF Q8Hands and props
BFS head LoRAHead swaps
4x-UltraSharpUpscaling
Depth Anything V2 LargeDepth and normals
ViTMatteHair and beard edges
Apple Vision, through a Swift toolFace landmarks and masks
ArcFaceA similarity score for faces
What ran locally, and what each model did on the poster.

Around them is a chain of scripts, from layout_to_comp through the face fixes, relighting, grain, atmosphere, the grade, the glow and the title, to validate_comp at the end. Each script rewrites only its own layers, and it knows which ones are its own from its *_ids.json.

Several sessions, one writer per file

Several Claude Code sessions worked at once: a main one, one for the face swaps, one for typography and one for QA. They shared one rule, one writer per file. A session that needed to write asked for a window and waited until the one holding it said “done”, and the engine passed between them through a hold file.

Writes are compare-and-swap, keyed by the manifest’s hash. A script checks the hash, makes a backup, writes and validates the file, so two sessions can’t quietly overwrite each other.

Mirror the plate first

headswap.py repaints only the face, through a noise mask built from Apple Vision’s landmarks, and the teammate’s beard comes along on purpose. Every swap gets an ArcFace score against the teammate. On the first test the swap scored 0.48, where the actor originally in the plate scored 0.02. Then I calibrated it: real photos of the same person score between 0.52 and 0.82 against each other, so about 0.6 is close to as high as it gets.

via GIPHY

When the head in the plate faced the other way from the photo, the score collapsed, to 0.14 and 0.12. The fix was to mirror the plate before the swap, never after.

Swapped onto a head turned the other way, a face scored 0.14. So the plate gets mirrored to match the photo first, and the swap comes after.

One teammate’s narrow face came out squashed. A Klein edit that brought the plate to their proportions first took the score from 0.51 to 0.70.

I had about five phone photos of each teammate. multiswap.py uses one main image at 1024 pixels plus three face crops at 512, because four full images at 1024 put the Mac under critical memory pressure. Only photos taken from the same side as the plate helped, and giving it all six of one teammate’s photos broke a profile. One neutral photo from the front could beat all the rest, and a big smile made things worse. The score didn’t get the last word either. I once picked a 0.51 over a 0.60, so every swap ended on a sheet comparing the options side by side.

Four of the characters aren’t human, so each one first needed a human head inside the costume. Characters with a beard of their own had to be shaved first and swapped after, since doing both in one step with one mask left a beard on the neck.

Edit the whole crop

Hands holding props were their own problem. Editing just a box on the 4B model gave me a second head in a hood, or a white back, because the hole gets sampled from noise in four steps.

edit_whole.py runs on 9B and edits the whole crop instead, with the grip from the original image as a guide. Then it aligns the result back onto the poster with SIFT and RANSAC, then ECC, matches the color on a ring around the edit and pastes in only the polygon. Everything outside the polygon stays as it was.

A box edit filled the hole from noise, sometimes with a second head. Editing the whole crop, aligning it back and pasting only the polygon left everything around it alone.

The real props are cut out of photos and pasted in. Our team’s stick, the one with everyone’s names on it, became a glowing purple blade, and when the model repainted it, the names came out scrambled. That’s why the props stayed real.

Light from both sides

The lighting brief came out of four rounds of questions: dark and dramatic, with a soft light from the front, a blue rim from the left for the heroes and a red one from the right for the villains, meeting on the one character who’s on both sides.

Depth and normals feed the relighting. To darken, I used a gray Multiply, which keeps the hue, and per-channel Curves added the red. The bloom is the poster minus the poster without its light sources, with anamorphic flares on top, and one grain goes over everything at the end.

The blades’ glow sits at the top level of the file, because inside a folder isolated with Curves, a Screen layer colors its own black.

Ships, and things to find

Text prompts couldn’t make the ships, so a script sets up one 3D camera and a flat sketch, runs a Klein edit for each group, cuts the ships out with Vision and places them with phase correlation, then adds each one as a body layer and a Linear Dodge light layer.

Repainting the background took a split: the color comes from the source and only the luminance detail from the repaint. Doing it per channel turned the foam olive.

There are easter eggs for the team: our shack on the edge of the station, an air conditioner in the front row, a frozen puck, a guitar with an engine and a door covered in stickers as a hatch. Each one needed something to hold on to, since nothing in the scene has a floor.

800 megapixels and not one more

The final file has 567 layers and uses 799.8 of its 800 MP. The documentation says the limit is 100 MP, and the app’s source code says it’s the smaller of 800 MP and the memory divided by 16, which on 24 GB comes to 800. Image pixels and mask pixels count separately, and a file that goes over is rejected without a word.

The real cap is 800 MP, though the docs say 100. Three days in, 83% was gone, and October’s 53 layers had to share the last 27.

The first three days used about 83% of it, so October’s 53 layers had to fit in about 27 MP. A few things made room. The alternates sit as hidden ALT layers in the same spot, and their cap shrank from 3 MP to 2 and then to 0.6. With maskSourceID clipping, one hidden mask drives several Curves layers, and smooth layers are stored at a quarter of their size.

Checking at print size

I checked every change at 100%, the size it prints. That’s how I caught a stranger the model had painted in where one character’s hand holds a mask.

For review, the file renders into 12 views to look through, and the type check runs at full scale, because at half scale it reported false differences.

On October 1 a read-only review found three flaws and six fixes, like one character at 114 ppi, under 120, and a blue smudge of 15 by 25 mm. Its false alarms got written down too. One thing worth knowing from that round: Apple Vision gives a head’s angle in 45° steps.

The bug I hit most was light layers leaving ghosts along their edges. A diagnostic script finds them, and copying the alpha profile fixes them.

Six versions to print

There were six versions, from 514 layers in v1 to 567 in v6. Each one ships a 6000 by 9000 TIFF, a TIFF with 3 mm of bleed at 6060 by 9060, an A3 strip and a JPEG, all in sRGB. Between versions, the diff shows up only where something was fixed, and it’s 0 on the type.

What I’d tell the print shop: send it in sRGB without converting to CMYK, ask for bleed and the paper profile, and print the strip on satin or semi-gloss.

A video built from the file

The behind-the-scenes video is made with HyperFrames, which is HTML plus a GSAP timeline, at 1920 by 1080 and 60 fps, in Hebrew and cut to music. It reads the master and never writes to it. Nothing in it is a screen recording: a subclass of the renderer rebuilds the poster from its layers, plus 8 plates pulled apart for a 3D explode.

The first version, 70 seconds at 30 fps in a dark sci-fi style, was rejected. I tried four directions after that, and a personnel file won. It opens on the cover of file no. 518, then a memo, a form for each character, an appendix of rejected attempts with real failures in it, an inventory of the layers, the build with a layer counter running, an “approved” stamp and a last memo signed “MacBook Air (requesting leave)”.

The music has a live drummer, at about 98 to 101 BPM, and the scene cuts lock to the beats. There are two edits, 46 and 85 seconds long.

Running out of memory

The Mac has 24 GB of unified memory, and the engine took about 13 of it. What got it under control was no partial offload on MPS, a tiled VAE at 512, attention capped at 512 queries a slice and weights built straight on the GPU. The swap spike went from 3.5 GB to 0.6.

via GIPHY

There’s a guard on the engine too. It stops after 20 seconds of critical memory pressure, when swap grows abnormally or when free disk drops under 3 GB, and each engine run takes at most 2 prompts.

Licenses, and next time

I passed on Sapiens2 because its license bars deepfakes, and face swaps sit too close to that line. Depth Anything V2 is licensed for non-commercial use, which an office poster is.

Next time I’d plan the pixel budget on day one, with a cap for the ALT layers from the start, lock the layout before touching the light, and ask everyone for photos from several angles up front.