Files
DynamicWallpaperCreator/docs/FILE_FORMAT.md
2026-07-11 22:52:23 -04:00

187 lines
7.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# The macOS Dynamic Desktop `.heic` File Format
This document explains exactly what bytes this app writes and why, for
anyone extending the project or just curious how Dynamic Desktop wallpapers
work under the hood. Apple has never published a specification for this
format — everything below was independently reverse-engineered by the
macOS developer community over the years, and was **re-verified during the
development of this app** by decoding a real, Apple-shipped dynamic
wallpaper file directly (see "Verification" below).
## 1. Container: HEIF/HEIC with multiple top-level images
A `.heic` file is a HEIF container (the same family of formats used for
iPhone photos). Normally it holds exactly one image. A Dynamic Desktop
wallpaper is the same container format, just holding **several independent,
full-resolution images** as separate top-level items instead of one — think
of it like a zip file with N pictures inside, rather than one image with N
animation frames.
macOS's own Photos/Preview/QuickLook stack can create files like this
through `CGImageDestination`, by calling `CGImageDestinationAddImage`
repeatedly on the same destination before finalizing it. That's exactly
what [`HeicBuilder.swift`](../src/main/resources/native/HeicBuilder.swift)
does:
```swift
let dest = CGImageDestinationCreateWithURL(url, "public.heic", images.count, nil)
for (index, image) in images.enumerated() {
CGImageDestinationAddImage(dest, image, properties) // (image 0 also carries metadata, see below)
}
CGImageDestinationFinalize(dest)
```
This is a capability of Apple's ImageIO framework specifically — there is no
equivalent in Java's `ImageIO`, and general-purpose command-line HEIC
encoders like `heif-enc` don't expose it either (they support single images,
"bursts"/animation sequences via `-S`, or resolution pyramids, but not
independent top-level image collections). That's the whole reason this
project ships a small Swift helper rather than doing everything in pure
Java — see [BUILD.md](BUILD.md) for how that helper is compiled.
## 2. Metadata: a binary property list, base64-encoded, in a custom XMP tag
Attached to **image index 0 only**, there's an XMP metadata tag under a
private Apple namespace:
- Namespace URI: `http://ns.apple.com/namespace/1.0/`
- Prefix: `apple_desktop`
- Tag name: `apr` (appearance mode) or `solar` (sun-position mode)
- Value: a `bplist00` (Apple binary property list), serialized to bytes, then
base64-encoded into a plain string, which is what actually gets stored as
the XMP tag's value.
In Swift this looks like:
```swift
let plistData = try PropertyListSerialization.data(fromPropertyList: dict, format: .binary, options: 0)
let base64 = plistData.base64EncodedString()
let tag = CGImageMetadataTagCreate(namespace, "apple_desktop", "apr", .string, base64 as CFTypeRef)
```
### 2a. Appearance mode (`apple_desktop:apr`)
Exactly two images: index 0 is shown in Light Mode, index 1 in Dark Mode. The
plist is a flat dictionary:
```
{
l = 0; // index of the "light" image
d = 1; // index of the "dark" image
}
```
### 2b. Solar mode (`apple_desktop:solar`)
Any number of images (Apple's own wallpapers typically use 16), each tagged
with the sun's position when it should be shown:
```
{
si = (
{ i = 0; a = -10.0; z = 60.0; }, // i = image index, a = altitude (degrees), z = azimuth (degrees)
{ i = 1; a = 15.0; z = 90.0; },
{ i = 2; a = 60.0; z = 180.0; },
...
);
ap = { l = 2; d = 0; }; // which frame index best represents "light" / "dark" for accessibility fallback
}
```
- **Altitude (`a`)** — the sun's height above the horizon in degrees. `0` is
the horizon, `90` is directly overhead, negative values are below the
horizon (night).
- **Azimuth (`z`)** — the sun's compass direction in degrees, `0``360`.
- **`ap`** — a secondary hint (same `l`/`d` shape as appearance mode) telling
macOS which single frame to fall back to when it just needs "a light one"
or "a dark one" rather than the full solar animation (e.g. for
accessibility or low-power contexts). This app computes it automatically
from whichever image has the highest/lowest altitude, unless you
explicitly check "Light Ref." / "Dark Ref." on a specific row in the
Solar tab.
This app does not currently implement the third known mode, time-of-day
scheduling (`apple_desktop:h24`, keyed by `ti`/`t`/`i` instead of `si`/`a`/`z`),
since it wasn't part of the request this project was built for. It would
slot into `HeicBuilder.swift` the same way `solar` does, if needed later.
## 3. Verification against a real Apple wallpaper
Rather than trusting secondhand write-ups alone, the exact schema above was
confirmed directly against a dynamic wallpaper Apple ships with macOS,
`/System/Library/Desktop Pictures/Sonoma.heic`, using the `inspect` command
built into the native helper:
```
$ heic-builder inspect "/System/Library/Desktop Pictures/Sonoma.heic"
images: 2
image 0 apple_desktop:apr = {
d = 1;
l = 0;
}
```
That is a live decode of Apple's own file — namespace, prefix, tag name
(`apr`), and the `l`/`d` key structure all matched what this project already
implemented. The `solar` structure (`si`/`ap`/`i`/`a`/`z`) is corroborated by
multiple independent community write-ups (NSHipster's "macOS Dynamic
Desktop" article and a widely cited 2018 reverse-engineering gist), and this
project's own `heic-builder inspect` command was used to confirm that files
*this app generates* round-trip through Apple's ImageIO the same way: build
a file, inspect it back, and the decoded plist matches what was requested
byte-for-byte.
If you ever need to re-verify or debug this yourself, `heic-builder inspect`
works on any `.heic` file, including ones this app produced or ones from
`/System/Library/Desktop Pictures/`.
## 4. The `job.json` hand-off format
This is purely an internal implementation detail — the Java GUI never writes
a `.heic` file itself; it writes a small JSON description of what to build to
a temp file, and runs `heic-builder build <that file>`. Documented here in
case you want to drive the helper directly (e.g. from a script) without going
through the GUI at all.
**Appearance mode:**
```json
{
"mode": "appearance",
"output": "/absolute/path/out.heic",
"quality": 0.9,
"light": "/absolute/path/light.png",
"dark": "/absolute/path/dark.png"
}
```
**Solar mode:**
```json
{
"mode": "solar",
"output": "/absolute/path/out.heic",
"quality": 0.9,
"images": [
{ "path": "/absolute/path/01.png", "altitude": -10.0, "azimuth": 60.0,
"lightReference": false, "darkReference": true },
{ "path": "/absolute/path/02.png", "altitude": 60.0, "azimuth": 180.0,
"lightReference": true, "darkReference": false }
]
}
```
Notes:
- `quality` is the HEIC lossy compression quality, `0.0``1.0` (higher = better
quality, larger file). Optional, defaults to `0.9`.
- `lightReference` / `darkReference` are optional booleans; at most one image
should have each set to `true`. If neither is set anywhere, the helper
picks the highest-altitude image as the light reference and the
lowest-altitude image as the dark reference automatically.
- Input images should all share the same pixel dimensions. The Java app
warns (non-fatally) if they don't; the helper itself does not resize them.
- `heic-builder build` prints one line of progress per image to stdout, then
either `OK <output path>` on success (exit code 0) or `ERROR: <message>`
to stderr (non-zero exit code) on failure. The Java app streams this
output straight into the log panel at the bottom of the window.