Skip to content

Dynamic Desktop HEIC

quiltwright.dynamic

Apple Dynamic Desktop HEIC packer.

A Dynamic Desktop is one HEIF file holding several stills plus XMP on image 0 that tells macOS which frame to show. The payload is a base64 binary plist in the apple_desktop namespace:

  • apr -- light/dark appearance (two frames)
  • solar -- sun altitude/azimuth (any number of frames)
  • h24 -- fraction of the local day (any number of frames)

None of this is POV-Ray's clock. That identifier is POV-Ray's animation parameter (+K / Clock=): it steps a scene through an internal 0-1 (or Initial_Clock-Final_Clock) loop. It has nothing to do with the Mac's wall clock or the sun. Real time of day lives only in this metadata, which macOS reads against Location Services (solar) or the system clock (h24).

Frames are finished RGB stills -- woven _native_ holograms or ordinary 2D renders. Woven frames encode lossless with 4:4:4 chroma; photographic HEVC 4:2:0 would mix the per-channel views and destroy the weave.

Typical usage::

from quiltwright.dynamic import AppearanceMap, DynamicSpec, save_dynamic_heic

spec = DynamicSpec.from_appearance(light_rgb, dark_rgb, lossless=True)
save_dynamic_heic(spec, "scene.heic")

Part of Quiltwright -- https://github.com/Flux-Frontiers/quiltwright Author: Eric G. Suchanek, PhD

AppearanceMap(light=0, dark=1) dataclass

Image indices for System Settings Light / Dark (static).

Parameters:

Name Type Description Default
light int

Index of the light-appearance frame.

0
dark int

Index of the dark-appearance frame.

1

DynamicSpec(frames, appearance, solar=(), times=(), lossless=False) dataclass

Frames plus the metadata that makes a HEIC a Dynamic Desktop.

Exactly one of solar or times may be set; if both are empty this is appearance-only. appearance is always required (solar/h24 files still need Light/Dark fallbacks).

Parameters:

Name Type Description Default
frames tuple[ndarray, ...]

RGB uint8 arrays, identical (H, W, 3).

required
appearance AppearanceMap

Light/Dark fallbacks.

required
solar tuple[SolarItem, ...]

Solar anchors, or ().

()
times tuple[TimeItem, ...]

Time-of-day anchors, or ().

()
lossless bool

Encode without HEVC 4:2:0. Required for woven frames.

False

from_appearance(light, dark, *, lossless=False) classmethod

Two-frame Light/Dark wallpaper.

Parameters:

Name Type Description Default
light ndarray

RGB frame for light appearance.

required
dark ndarray

RGB frame for dark appearance.

required
lossless bool

See the class docstring.

False

Returns:

Type Description
DynamicSpec

An appearance-only spec.

Source code in src/quiltwright/dynamic.py
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
@classmethod
def from_appearance(
    cls,
    light: np.ndarray,
    dark: np.ndarray,
    *,
    lossless: bool = False,
) -> DynamicSpec:
    """Two-frame Light/Dark wallpaper.

    :param light: RGB frame for light appearance.
    :param dark: RGB frame for dark appearance.
    :param lossless: See the class docstring.
    :return: An appearance-only spec.
    """
    return cls(
        frames=(np.asarray(light), np.asarray(dark)),
        appearance=AppearanceMap(0, 1),
        lossless=lossless,
    )

SolarItem(index, altitude, azimuth) dataclass

One solar anchor: which frame to show at this sun position.

Parameters:

Name Type Description Default
index int

Frame index in the HEIC.

required
altitude float

Sun altitude in degrees (horizon = 0, zenith = 90).

required
azimuth float

Sun azimuth in degrees, [0, 360).

required

TimeItem(index, time) dataclass

One time-of-day anchor: which frame to show at this fraction of the day.

Parameters:

Name Type Description Default
index int

Frame index in the HEIC.

required
time float

Fraction of the local day, [0, 1). Midnight is 0, noon is 0.5. This is the Mac's clock, not POV-Ray's clock.

required

appearance_plist(mapping)

Binary plist for apple_desktop:apr.

Parameters:

Name Type Description Default
mapping AppearanceMap

Light/Dark image indices.

required

Returns:

Type Description
bytes

Apple binary property list bytes.

Source code in src/quiltwright/dynamic.py
206
207
208
209
210
211
212
def appearance_plist(mapping: AppearanceMap) -> bytes:
    """Binary plist for ``apple_desktop:apr``.

    :param mapping: Light/Dark image indices.
    :return: Apple binary property list bytes.
    """
    return plistlib.dumps({"l": mapping.light, "d": mapping.dark}, fmt=plistlib.FMT_BINARY)

is_woven_stem(stem)

Whether stem names a woven native frame.

Parameters:

Name Type Description Default
stem str

Filename without its extension.

required

Returns:

Type Description
bool

True if the stem ends in _native_<serial>.

Source code in src/quiltwright/dynamic.py
55
56
57
58
59
60
61
def is_woven_stem(stem: str) -> bool:
    """Whether *stem* names a woven native frame.

    :param stem: Filename without its extension.
    :return: ``True`` if the stem ends in ``_native_<serial>``.
    """
    return NATIVE_STEM.search(stem) is not None

metadata_for(spec)

Pick the XMP tag and plist for spec.

Parameters:

Name Type Description Default
spec DynamicSpec

A validated spec.

required

Returns:

Type Description
tuple[str, bytes]

(tag, xmp_bytes).

Source code in src/quiltwright/dynamic.py
270
271
272
273
274
275
276
277
278
279
280
def metadata_for(spec: DynamicSpec) -> tuple[str, bytes]:
    """Pick the XMP tag and plist for *spec*.

    :param spec: A validated spec.
    :return: ``(tag, xmp_bytes)``.
    """
    if spec.solar:
        return "solar", xmp_payload("solar", solar_plist(spec.solar, spec.appearance))
    if spec.times:
        return "h24", xmp_payload("h24", time_plist(spec.times, spec.appearance))
    return "apr", xmp_payload("apr", appearance_plist(spec.appearance))

parse_clock_time(value)

Parse HH:MM or HH:MM:SS to a fraction of a 24-hour day.

Parameters:

Name Type Description Default
value str

A wall-clock time, 24-hour.

required

Returns:

Type Description
float

seconds / 86400.

Raises:

Type Description
ValueError

If the string is not a time, or is out of range.

Source code in src/quiltwright/dynamic.py
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
def parse_clock_time(value: str) -> float:
    """Parse ``HH:MM`` or ``HH:MM:SS`` to a fraction of a 24-hour day.

    :param value: A wall-clock time, 24-hour.
    :return: ``seconds / 86400``.
    :raises ValueError: If the string is not a time, or is out of range.
    """
    parts = value.strip().split(":")
    if len(parts) not in (2, 3):
        raise ValueError(f"time must be HH:MM or HH:MM:SS, got {value!r}")
    try:
        hours = int(parts[0])
        minutes = int(parts[1])
        seconds = int(parts[2]) if len(parts) == 3 else 0
    except ValueError as exc:
        raise ValueError(f"time must be HH:MM or HH:MM:SS, got {value!r}") from exc
    if not (0 <= hours < 24 and 0 <= minutes < 60 and 0 <= seconds < 60):
        raise ValueError(f"time out of range: {value!r}")
    return (hours * 3600 + minutes * 60 + seconds) / 86400.0

read_dynamic_metadata(path)

Decode apple_desktop metadata from a Dynamic Desktop HEIC.

Parameters:

Name Type Description Default
path str | Path

A .heic written by :func:save_dynamic_heic, or one of Apple's.

required

Returns:

Type Description
dict

The decoded plist as a plain dict, plus tag.

Raises:

Type Description
RuntimeError

If pillow-heif is not installed.

ValueError

If no apple_desktop tag is present.

Source code in src/quiltwright/dynamic.py
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
def read_dynamic_metadata(path: str | Path) -> dict:
    """Decode ``apple_desktop`` metadata from a Dynamic Desktop HEIC.

    :param path: A ``.heic`` written by :func:`save_dynamic_heic`, or one
        of Apple's.
    :return: The decoded plist as a plain dict, plus ``tag``.
    :raises RuntimeError: If pillow-heif is not installed.
    :raises ValueError: If no ``apple_desktop`` tag is present.
    """
    pillow_heif = _require_heif()
    heif = pillow_heif.open_heif(path)
    xmp = heif.info.get("xmp") or heif.info.get("XMP")
    if not xmp:
        raise ValueError(f"{path} has no XMP")
    text = xmp.decode("utf-8", "replace") if isinstance(xmp, bytes) else str(xmp)
    for tag in ("solar", "h24", "apr"):
        key = f"apple_desktop:{tag}"
        match = re.search(key + r'\s*=\s*"([^"]+)"', text)
        if match is None:
            continue
        raw = base64.b64decode(match.group(1))
        body = plistlib.loads(raw)
        return {"tag": tag, "plist": body, "n_images": len(heif)}
    raise ValueError(f"{path} has no apple_desktop metadata")

save_dynamic_heic(spec, path)

Write a Dynamic Desktop HEIC.

Parameters:

Name Type Description Default
spec DynamicSpec

Frames and metadata.

required
path str | Path

Output path (.heic).

required

Returns:

Type Description
Path

The written path.

Raises:

Type Description
RuntimeError

If pillow-heif is not installed.

ValueError

If spec is invalid.

Source code in src/quiltwright/dynamic.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
def save_dynamic_heic(spec: DynamicSpec, path: str | Path) -> Path:
    """Write a Dynamic Desktop HEIC.

    :param spec: Frames and metadata.
    :param path: Output path (``.heic``).
    :return: The written path.
    :raises RuntimeError: If pillow-heif is not installed.
    :raises ValueError: If *spec* is invalid.
    """
    validate_spec(spec)
    pillow_heif = _require_heif()
    pillow_heif.register_heif_opener()
    from PIL import Image

    _, xmp = metadata_for(spec)
    images = [Image.fromarray(np.asarray(f)[..., :3].astype(np.uint8)) for f in spec.frames]
    out = Path(path)
    out.parent.mkdir(parents=True, exist_ok=True)
    save_kw: dict = {
        "format": "HEIF",
        "save_all": True,
        "append_images": images[1:],
        "xmp": xmp,
    }
    if spec.lossless:
        # quality=-1 is pillow-heif's lossless HEVC; chroma 444 keeps the
        # per-channel views of a woven frame from being subsampled together.
        save_kw["quality"] = -1
        save_kw["chroma"] = 444
    else:
        save_kw["quality"] = 95
    images[0].save(out, **save_kw)
    return out

solar_plist(items, appearance)

Binary plist for apple_desktop:solar.

Matches the decoded shape of Apple's The Lake.heic: ap fallbacks plus si anchors with i / a / z.

Parameters:

Name Type Description Default
items tuple[SolarItem, ...]

Solar anchors, in any order.

required
appearance AppearanceMap

Light/Dark fallbacks.

required

Returns:

Type Description
bytes

Apple binary property list bytes.

Source code in src/quiltwright/dynamic.py
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
def solar_plist(items: tuple[SolarItem, ...], appearance: AppearanceMap) -> bytes:
    """Binary plist for ``apple_desktop:solar``.

    Matches the decoded shape of Apple's ``The Lake.heic``: ``ap`` fallbacks
    plus ``si`` anchors with ``i`` / ``a`` / ``z``.

    :param items: Solar anchors, in any order.
    :param appearance: Light/Dark fallbacks.
    :return: Apple binary property list bytes.
    """
    body = {
        "ap": {"l": appearance.light, "d": appearance.dark},
        "si": [{"i": it.index, "a": float(it.altitude), "z": float(it.azimuth)} for it in items],
    }
    return plistlib.dumps(body, fmt=plistlib.FMT_BINARY)

spec_from_json(path, *, lossless=None)

Load a wallpapper-shaped JSON description.

Each entry needs fileName. Solar entries add altitude and azimuth; time entries add time (HH:MM or HH:MM:SS). isPrimary puts that frame at index 0 (Preview's still). isForLight / isForDark set the appearance fallbacks.

Parameters:

Name Type Description Default
path str | Path

JSON file. Image paths are relative to its directory.

required
lossless bool | None

See :func:spec_from_paths.

None

Returns:

Type Description
DynamicSpec

A :class:DynamicSpec.

Source code in src/quiltwright/dynamic.py
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
def spec_from_json(path: str | Path, *, lossless: bool | None = None) -> DynamicSpec:
    """Load a wallpapper-shaped JSON description.

    Each entry needs ``fileName``.  Solar entries add ``altitude`` and
    ``azimuth``; time entries add ``time`` (``HH:MM`` or ``HH:MM:SS``).
    ``isPrimary`` puts that frame at index 0 (Preview's still).
    ``isForLight`` / ``isForDark`` set the appearance fallbacks.

    :param path: JSON file.  Image paths are relative to its directory.
    :param lossless: See :func:`spec_from_paths`.
    :return: A :class:`DynamicSpec`.
    """
    json_path = Path(path).expanduser().resolve()
    entries = json.loads(json_path.read_text())
    if not isinstance(entries, list) or len(entries) < 2:
        raise ValueError(f"{json_path} must be a JSON array of at least 2 frames")

    primary = next((i for i, e in enumerate(entries) if e.get("isPrimary")), 0)
    order = [primary] + [i for i in range(len(entries)) if i != primary]
    remap = {old: new for new, old in enumerate(order)}

    paths: list[Path] = []
    solar: list[SolarItem] = []
    times: list[TimeItem] = []
    light_idx = 0
    dark_idx = len(entries) - 1
    has_solar = False
    has_time = False
    for old in order:
        entry = entries[old]
        name = entry.get("fileName")
        if not name:
            raise ValueError(f"entry {old} has no fileName")
        paths.append((json_path.parent / name).resolve())
        new = remap[old]
        if entry.get("isForLight"):
            light_idx = new
        if entry.get("isForDark"):
            dark_idx = new
        if "altitude" in entry or "azimuth" in entry:
            if "altitude" not in entry or "azimuth" not in entry:
                raise ValueError(
                    f"entry {old} ({name}) needs both altitude and azimuth, "
                    f"got only {'altitude' if 'altitude' in entry else 'azimuth'}"
                )
            has_solar = True
            solar.append(
                SolarItem(
                    index=new,
                    altitude=float(entry["altitude"]),
                    azimuth=float(entry["azimuth"]),
                )
            )
        if "time" in entry:
            has_time = True
            times.append(TimeItem(index=new, time=parse_clock_time(str(entry["time"]))))
    if has_solar and has_time:
        raise ValueError(f"{json_path} mixes altitude/azimuth with time")
    return spec_from_paths(
        paths,
        appearance=AppearanceMap(light=light_idx, dark=dark_idx),
        solar=tuple(solar),
        times=tuple(times),
        lossless=lossless,
    )

spec_from_paths(paths, *, appearance, solar=(), times=(), lossless=None)

Build a spec from still files on disk.

Parameters:

Name Type Description Default
paths list[Path]

Frame files, in HEIC order.

required
appearance AppearanceMap

Light/Dark fallbacks.

required
solar tuple[SolarItem, ...]

Solar anchors, or empty.

()
times tuple[TimeItem, ...]

Time-of-day anchors, or empty.

()
lossless bool | None

None means "lossless if any stem is woven".

None

Returns:

Type Description
DynamicSpec

A :class:DynamicSpec.

Raises:

Type Description
ValueError

If a woven frame would be lossy-encoded.

Source code in src/quiltwright/dynamic.py
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
def spec_from_paths(
    paths: list[Path],
    *,
    appearance: AppearanceMap,
    solar: tuple[SolarItem, ...] = (),
    times: tuple[TimeItem, ...] = (),
    lossless: bool | None = None,
) -> DynamicSpec:
    """Build a spec from still files on disk.

    :param paths: Frame files, in HEIC order.
    :param appearance: Light/Dark fallbacks.
    :param solar: Solar anchors, or empty.
    :param times: Time-of-day anchors, or empty.
    :param lossless: ``None`` means "lossless if any stem is woven".
    :return: A :class:`DynamicSpec`.
    :raises ValueError: If a woven frame would be lossy-encoded.
    """
    frames = tuple(_load_rgb(p) for p in paths)
    woven = any(is_woven_stem(p.stem) for p in paths)
    if lossless is None:
        lossless = woven
    elif woven and not lossless:
        raise ValueError(
            "woven _native_ frames must be encoded lossless (HEVC 4:2:0 "
            "mixes the per-channel views). Pass lossless=True, or drop --lossy."
        )
    spec = DynamicSpec(
        frames=frames,
        appearance=appearance,
        solar=solar,
        times=times,
        lossless=lossless,
    )
    validate_spec(spec)
    return spec

time_plist(items, appearance)

Binary plist for apple_desktop:h24.

Parameters:

Name Type Description Default
items tuple[TimeItem, ...]

Time-of-day anchors.

required
appearance AppearanceMap

Light/Dark fallbacks.

required

Returns:

Type Description
bytes

Apple binary property list bytes.

Source code in src/quiltwright/dynamic.py
232
233
234
235
236
237
238
239
240
241
242
243
def time_plist(items: tuple[TimeItem, ...], appearance: AppearanceMap) -> bytes:
    """Binary plist for ``apple_desktop:h24``.

    :param items: Time-of-day anchors.
    :param appearance: Light/Dark fallbacks.
    :return: Apple binary property list bytes.
    """
    body = {
        "ap": {"l": appearance.light, "d": appearance.dark},
        "ti": [{"i": it.index, "t": float(it.time)} for it in items],
    }
    return plistlib.dumps(body, fmt=plistlib.FMT_BINARY)

validate_spec(spec)

Raise ValueError if spec cannot be a Dynamic Desktop.

Parameters:

Name Type Description Default
spec DynamicSpec

The spec to check.

required
Source code in src/quiltwright/dynamic.py
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
def validate_spec(spec: DynamicSpec) -> None:
    """Raise ``ValueError`` if *spec* cannot be a Dynamic Desktop.

    :param spec: The spec to check.
    """
    frames = spec.frames
    if len(frames) < 2:
        raise ValueError(f"a Dynamic Desktop needs at least 2 frames, got {len(frames)}")
    if spec.solar and spec.times:
        raise ValueError("solar and time-of-day metadata cannot both be set")
    shape = None
    for i, frame in enumerate(frames):
        arr = np.asarray(frame)
        if arr.ndim != 3 or arr.shape[2] < 3:
            raise ValueError(f"frame {i} must be RGB (H, W, 3), got {arr.shape}")
        if shape is None:
            shape = arr.shape[:2]
        elif arr.shape[:2] != shape:
            raise ValueError(
                f"frame {i} is {arr.shape[1]}x{arr.shape[0]}, expected {shape[1]}x{shape[0]}"
            )
    n = len(frames)
    for label, idx in (("light", spec.appearance.light), ("dark", spec.appearance.dark)):
        if not 0 <= idx < n:
            raise ValueError(f"appearance {label} index {idx} is out of range 0..{n - 1}")
    for item in spec.solar:
        if not 0 <= item.index < n:
            raise ValueError(f"solar index {item.index} is out of range 0..{n - 1}")
        if not -90.0 <= item.altitude <= 90.0:
            raise ValueError(f"solar altitude {item.altitude} is not in [-90, 90]")
        if not 0.0 <= item.azimuth < 360.0:
            raise ValueError(f"solar azimuth {item.azimuth} is not in [0, 360)")
    for item in spec.times:
        if not 0 <= item.index < n:
            raise ValueError(f"time index {item.index} is out of range 0..{n - 1}")
        if not 0.0 <= item.time < 1.0:
            raise ValueError(f"time fraction {item.time} is not in [0, 1)")

xmp_payload(tag, plist_bytes)

XMP packet wrapping a base64 plist as apple_desktop:<tag>.

Parameters:

Name Type Description Default
tag str

apr, solar, or h24.

required
plist_bytes bytes

Binary plist from the matching *_plist helper.

required

Returns:

Type Description
bytes

UTF-8 XMP packet.

Source code in src/quiltwright/dynamic.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
def xmp_payload(tag: str, plist_bytes: bytes) -> bytes:
    """XMP packet wrapping a base64 plist as ``apple_desktop:<tag>``.

    :param tag: ``apr``, ``solar``, or ``h24``.
    :param plist_bytes: Binary plist from the matching ``*_plist`` helper.
    :return: UTF-8 XMP packet.
    """
    if tag not in _XMP_TAG:
        raise ValueError(f"unknown apple_desktop tag {tag!r}")
    b64 = base64.b64encode(plist_bytes).decode("ascii")
    # Compact on one attribute so ImageIO and pillow-heif both see it.
    xml = (
        '<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>\n'
        '<x:xmpmeta xmlns:x="adobe:ns:meta/">\n'
        '<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">\n'
        f'<rdf:Description xmlns:apple_desktop="{_APPLE_NS}"\n'
        f' apple_desktop:{tag}="{b64}"/>\n'
        "</rdf:RDF>\n"
        "</x:xmpmeta>\n"
        '<?xpacket end="w"?>\n'
    )
    return xml.encode("utf-8")