Skip to content

Repository files navigation

mockframe

Turns an app screenshot into a perspective-rendered device image. No browser, no purchased 3D models, no GPU.

Rendered iPhone 16 Pro turned slightly left against a light gradient, its screen showing a deep blue placeholder design with MOCK FRAME set in large yellow type

uvx mockframe render today.png --auto-device --bg light -o hero.png

The housing is generated procedurally from millimetre specs and shaded with a software rasteriser written for the job. Adding a device is therefore a table entry, not an asset file.

Version 0.2.1, 58 tests passing. The default 1400 x 2415 image takes about four to five seconds on an Apple Silicon Mac.

Why not just buy a 3D model

The obvious route would be to buy an iPhone model and render it. Three reasons against, in this order.

Licensing. Models from Sketchfab, TurboSquid or CGTrader are mostly "editorial use only", or they forbid redistribution inside a product. Putting a model into a repo that other people clone is a different legal situation from rendering an image with it. Procedural geometry from publicly known dimensions sidesteps the problem instead of managing it.

Geometry. A phone housing is a rounded rect extruded along a curved edge profile. That is forty lines of code. An imported model brings material setups, scale questions and triangle soup with it, without solving anything that is hard here.

Choice of device. When the geometry comes from numbers, a new model is a table entry. With imported meshes it is a new file, a new material and a new calibration pass.

What that costs: the rasteriser handles exactly one class of object, and things a bought model would have brought along are still missing here — the camera bump, antenna lines, real glass refraction.

Architecture

graph TD
    CLI["cli.py — command line"]
    SCENE["scene.py — composition, presets"]
    GEO["geometry.py — mesh, projection"]
    RAST["raster.py — z-buffer, culling"]
    SHADE["shading.py — material, environments"]
    SCR["screen.py — warp, aspect check"]
    DEV["devices.py — device table, edge profile"]

    CLI --> SCENE
    SCENE --> GEO
    SCENE --> RAST
    SCENE --> SCR
    RAST --> SHADE
    GEO --> DEV
    SCR --> DEV
    SCENE --> DEV
Loading

devices.py imports nothing and is read by everything. That is why a new device is a table row rather than a code change.

Two decisions shape the rest. The glass plane is deliberately not rasterised with the housing; it is composited separately through a four-point perspective warp. Being planar, the warp is exact and sharper than interpolating a texture across triangles. And normals are computed analytically rather than averaged from neighbouring facets — that is the reason no faceting shows on the narrow side rail.

The full flow of a render, including the abort branch on a wrong aspect ratio: ARCHITECTURE.md.

Using it

With Claude Code — once, and after that "make me a hero image" is enough:

/plugin marketplace add moOritzl/claude-plugins
/plugin install mockframe@moritzlenhard

Without Claude Code, or with a different agent:

uvx mockframe render shot.png --auto-device -o hero.png

uvx fetches the package on first call and leaves nothing in your project. Without uv: brew install uv on macOS, otherwise the instructions from Astral.

The capture subcommand additionally needs macOS with Xcode; everything else is platform independent.

Development

Python 3.11 or newer. Everything lands in a venv inside the project folder, nothing in the system Python.

python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"

-e installs it editable, so changes to src/mockframe/ take effect immediately. [dev] pulls in pytest; without it you get only the runtime dependencies numpy, Pillow and scipy.

The skill deliberately always calls uvx mockframe, meaning the published version. When developing, call .venv/bin/mockframe ... directly.

Commands

mockframe stands here and below for whichever invocation you chose above: uvx mockframe with no install, .venv/bin/mockframe from the project folder, or a bare mockframe with the venv activated.

mockframe devices                                  # table with provenance
mockframe render shot.png --device iphone-16-pro --preset hero-left
mockframe render shot.png --auto-device --bg dark
mockframe render shot.png --auto-device --zoom top   # close up on the top
mockframe hdri-synth studio.npy                    # synthetic environment map
mockframe capture --screens today,history --render   # macOS only

--auto-device derives the device from the screenshot resolution. 1206 x 2622 is an iPhone 16 Pro, and the tool knows that.

Close-ups

Close-up of the upper half of a rendered iPhone against a light gradient, showing the status bar at 9:41, the dynamic island, small-caps labels reading PROCEDURAL and NO 3D MODELS, the full MOCK FRAME headline in large yellow type on deep blue and the paragraph below it, with the polished titanium rail running down the left edge

uvx mockframe render today.png --auto-device --zoom top -o header.png

--zoom top and --zoom bottom frame one half of the device — a navigation bar, a tab bar, a primary action. Half each, so the screen content is still readable; the two tile the device exactly. The crop sets the aspect ratio, so the output is roughly square rather than the usual portrait image, and --width sets its resolution.

This is a camera change, not a crop of a finished render: the focal length scales and the principal point shifts, so the perspective and the reflections on the rail stay correct and the full output resolution is used.

--crop x0,y0,x1,y1 takes a custom rect as fractions of the device. The two axes are measured differently, which matters: y selects a band off the whole device, and x is measured against the device's width within that band. Under yaw and roll the top of the phone does not sit at the horizontal middle of the whole device, so measuring against the full outline would leave dead space on one side.

Values outside 0..1 add air around the phone rather than cutting into the housing, which is what lets a rect frame the whole device with a controlled margin. That is how the default framing is defined — (-0.16, -0.08, 1.16, 1.08) — and there is no separate code path for it: every render is a crop rect, and a rect symmetric about the device centres it by construction.

A tighter crop magnifies the screenshot. The named zooms never upscale it, but past roughly 2x there is no more detail in the source — lower --width to get it back, since half the width is half the magnification for the same framing.

There is no corner preset on purpose. A corner shot shows the chassis and almost none of the app, and this tool exists to present a screenshot.

Transparency

The same rendered iPhone as a cutout on a transparent background, its soft contact shadow falling down and to the right with no background panel behind it

uvx mockframe render today.png --auto-device --bg none -o cutout.png

--bg none writes an RGBA PNG with the contact shadow kept in the alpha channel, so the cutout sits on whatever it is composited onto instead of floating above it. It needs a PNG output; the run aborts on a JPEG rather than silently filling the transparency with black.

If the screenshot does not fit the device, the run aborts instead of stretching silently:

Aborted: screenshot 1920x1080 (ratio 1.7778) does not match
iPhone 16 Pro (Natural Titanium) (expected 0.4600).

That is deliberate. A stretched render looks almost right, and the mistake usually surfaces only once the image has been published.

HDRI

The built-in environment is a formula made of Gaussian lobes. It is clean but smooth, because a formula has no structure. Switching to a real environment map is the largest jump in quality per line of code, and it replaces exactly one function — the interface is sample(R): a reflection vector in, linear radiance out.

Analytic studio environment Synthetic HDRI
Render with the analytic studio environment, bright titanium rail The same render with a synthetic HDRI, darker rail with a harder falloff
Default, no file needed --hdri studio.npy

Both images are the same call, the same device, the same preset. Only the environment differs. The synthetic map is not automatically the better-looking choice — it has a dark base tone with a single softbox, which gives the rail more contrast but also a harder edge. It exists to demonstrate the sampling path:

mockframe hdri-synth studio.npy
mockframe render shot.png --hdri studio.npy

For product images, use a real studio HDRI from Poly Haven; those are CC0. For .exr or .hdr add pip install -e ".[hdr]", read the file yourself and pass it to HDRIEnvironment(array). This is the one path here that needs an installed copy rather than uvx — it does not run through the command line but in your own Python.

Environment maps are not checked in, *.npy is ignored. The synthetic one is 6 MB and reproducible bit for bit from the code; a test pins the hash.

Examples

Every image in this README is rendered from examples/app-screen.png, a real iOS screenshot of a placeholder screen — a small SwiftUI app in tools/mockscreen/, captured from the Simulator through mockframe capture. It shows no third-party app UI and no personal data, so the images carry no rights that are not ours.

With a booted iPhone simulator, the whole set rebuilds:

tools/mockscreen/capture.sh examples/app-screen.png
mockframe hdri-synth examples/studio.npy
mockframe render examples/app-screen.png --device iphone-16-pro --bg light --width 900 -o examples/hero_light.png
mockframe render examples/app-screen.png --device iphone-16-pro --bg light --hdri examples/studio.npy --width 900 -o examples/hero_hdri.png
mockframe render examples/app-screen.png --device iphone-16-pro --bg none --width 900 -o examples/hero_transparent.png
mockframe render examples/app-screen.png --device iphone-16-pro --zoom top --bg light --width 900 -o examples/zoom_top.png

--width 900 only for the checked-in examples, not because the default is wrong: at 340 px in this README a default 1400 px render is four times the pixels anyone sees, and the repo carries them forever.

The environment map is regenerated rather than checked in, because *.npy is ignored and it is 6 MB.

capture.sh builds the app with swiftc straight into an .app bundle — a single-file SwiftUI app needs no Xcode project — then installs it, pins the status bar to 9:41 so re-runs match, and screenshots it. It is a maintainer tool and is not part of the package. The iPhone 17 Pro simulator shoots 1206 x 2622, which --auto-device resolves to iphone-16-pro.

Tests

pytest -q

test_convergence.py is the test that matters. With correct interpolation the image must not depend on the triangle count. It exists because in the prototype the barycentric weights were mapped one position out: w0 is the edge function for v0 to v1 and therefore the weight of v2, not of v1. The bug was plainly visible in the image as a ladder pattern on the side rail, yet high-frequency metrics dropped only from 2.93 to 2.47. The convergence test fails unambiguously.

For renderers this is generally the test that carries: vary a parameter that must not change the result, and check that it does not.

Accuracy of the dimensions

Width, height and thickness come from Apple's tech specs. Corner radius and bezel width are not officially documented and are approximations. Every table entry carries a source field, and a test fails if it is empty. Without that, in three months nobody can tell which figure was verified and which one was guessed.

Status

Five devices, four camera presets, four backgrounds, an analytic studio environment, HDRI sampling, an aspect ratio check, and capture from the iOS Simulator.

Missing: the camera bump on the back, antenna lines, a floor reflection, the duo preset. The duo preset needs the camera bump first, because one device shows up cropped there.

The rasteriser is a Python loop over triangles. Vectorising over tiles, or Numba, would cut render time noticeably — but that is comfort, and it comes after image quality.

License and legal

MIT, see LICENSE.

NOTICE records what the license does not cover: the project is not affiliated with Apple, the repo contains no third-party assets, and images destined for the App Store are additionally subject to Apple's own marketing guidelines.

About

Command-line tool that renders an app screenshot into a perspective iPhone mockup. Procedural housing geometry from millimetre specs, its own software rasteriser, no browser, no GPU, no purchased 3D models.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages