Metadata-Version: 2.4
Name: mockframe
Version: 0.2.0
Summary: Perspective device mockups from app screenshots, rendered procedurally
Author: Moritz Lenhard
License-Expression: MIT
Project-URL: Repository, https://github.com/moOritzl/mockframe
Keywords: mockup,renderer,rasterizer,screenshot,ios
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Multimedia :: Graphics :: 3D Rendering
Requires-Python: >=3.11
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.24
Requires-Dist: Pillow>=10.0
Requires-Dist: scipy>=1.10
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Provides-Extra: hdr
Requires-Dist: imageio>=2.31; extra == "hdr"
Dynamic: license-file

# mockframe

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

<img src="https://raw.githubusercontent.com/moOritzl/mockframe/main/examples/hero_light.png" width="420" alt="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">

```bash
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, 49 tests passing. A 1400 x 1750 image takes about three to four 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

```mermaid
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
```

`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](https://github.com/moOritzl/mockframe/blob/main/ARCHITECTURE.md).

## Using it

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

```bash
/plugin marketplace add moOritzl/claude-plugins
```

```bash
/plugin install mockframe@moritzlenhard
```

Without Claude Code, or with a different agent:

```bash
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](https://docs.astral.sh/uv/getting-started/installation/).

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.

```bash
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.

```bash
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

<img src="https://raw.githubusercontent.com/moOritzl/mockframe/main/examples/zoom_top.png" width="640" alt="Close-up of the top of a rendered iPhone against a light gradient, showing the status bar at 9:41, small-caps labels and the start of MOCK FRAME in large yellow type on deep blue, with the polished titanium rail running down the left edge">

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

`--zoom top` and `--zoom bottom` frame one end of the device — a navigation bar, a tab bar, a primary action. The crop sets the aspect ratio, so the output is a wide banner 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.

A tighter crop magnifies the screenshot. Both named zooms stay at native sharpness, 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

`--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 |
|---|---|
| <img src="https://raw.githubusercontent.com/moOritzl/mockframe/main/examples/hero_light.png" width="260" alt="Render with the analytic studio environment, bright titanium rail"> | <img src="https://raw.githubusercontent.com/moOritzl/mockframe/main/examples/hero_hdri.png" width="260" alt="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:

```bash
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:

```bash
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 -o examples/hero_light.png
mockframe render examples/app-screen.png --device iphone-16-pro --bg light --hdri examples/studio.npy -o examples/hero_hdri.png
mockframe render examples/app-screen.png --device iphone-16-pro --zoom top --bg light -o examples/zoom_top.png
```

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

```bash
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](https://github.com/moOritzl/mockframe/blob/main/LICENSE).

[NOTICE](https://github.com/moOritzl/mockframe/blob/main/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.
