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

7.5 KiB
Raw Permalink Blame History

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) 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:

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, 0360.
  • 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:

{
  "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:

  • quality is the HEIC lossy compression quality, 0.01.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.