7.5 KiB
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
does:
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 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) orsolar(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:
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.0is the horizon,90is directly overhead, negative values are below the horizon (night). - Azimuth (
z) — the sun's compass direction in degrees,0–360. ap— a secondary hint (samel/dshape 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:
{
"mode": "appearance",
"output": "/absolute/path/out.heic",
"quality": 0.9,
"light": "/absolute/path/light.png",
"dark": "/absolute/path/dark.png"
}
Solar mode:
{
"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:
qualityis the HEIC lossy compression quality,0.0–1.0(higher = better quality, larger file). Optional, defaults to0.9.lightReference/darkReferenceare optional booleans; at most one image should have each set totrue. 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 buildprints one line of progress per image to stdout, then eitherOK <output path>on success (exit code 0) orERROR: <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.