skip to content

blog

Your video on the macOS lock screen needs temporal sub-layers, even empty ones

phonto puts a video on the macOS lock screen with one command. Getting it to play past the first lock comes down to one encoder setting.

“Nature loves to hide.”

Φύσις κρύπτεσθαι φιλεῖ.

Heraclitus, fragment B123

phonto is a small command-line tool that plays video as your wallpaper. On macOS it can also put that video on the lock screen:

shell
phonto install-live-lockscreen ~/Videos/harbour.mp4

Open System Settings → Wallpaper, pick the video in the Phonto section and lock the screen. It plays. Unlock, lock again, put the Mac to sleep and wake it. Still playing.

We’ve run it on macOS 26 and 27, on Apple Silicon. A macOS update that ships a new Aerials catalog takes phonto’s videos out of System Settings, and you run the command again. The lock screens page has the steps and the options.

The command takes a while because it encodes your video again first, and the new file is 125 to 250 MB for 20 seconds of 4K. That encode is most of this post. The rest is a JSON file Apple would probably rather you left alone.

The lock screen isn’t ours

The obvious approach is a window behind the desktop icons that plays the video. phonto does that for the desktop, and it’s useless for the lock screen. As soon as the screen locks, macOS hides every window. There’s no public API for putting your own wallpaper there either.

So phonto doesn’t play anything on the lock screen. macOS does. WallpaperAerialsExtension plays Apple’s Aerials, the slow aerial shots of coastlines and cities. It’s a signed system extension, and macOS lets it draw on the lock screen. It reads its list of Aerials from a file in your home folder, and you can write to that file. You can see where this is going.

One more Aerial

The list lives here:

text
~/Library/Application Support/com.apple.wallpaper/aerials/manifest/entries.json

The videos and thumbnails sit next to it in aerials/videos/ and aerials/thumbnails/, each named by a UUID. phonto writes its own video and thumbnail into those folders, adds an entry to entries.json and restarts WallpaperAgent, which brings up a fresh copy of the extension. This is the entry it writes for harbour.mp4:

json
{
  "id": "CB528B3F-C8B5-58FC-BB89-8B0ED2436C9B",
  "categories": ["8C75F1C2-7E7E-4B5C-9C5C-50484F4E544F"],
  "subcategories": ["8C75F1C2-7E7E-4B5C-9C5C-535542434154"],
  "previewImage": "file:///Users/you/Library/Application%20Support/com.apple.wallpaper/aerials/thumbnails/CB528B3F-C8B5-58FC-BB89-8B0ED2436C9B.png",
  "accessibilityLabel": "harbour",
  "localizedNameKey": "harbour",
  "includeInShuffle": false,
  "pointsOfInterest": {},
  "preferredOrder": 0,
  "shotID": "PHONTO_CB528B3F",
  "showInTopLevel": true,
  "url-4K-SDR-240FPS": "file:///Users/you/Library/Application%20Support/com.apple.wallpaper/aerials/videos/CB528B3F-C8B5-58FC-BB89-8B0ED2436C9B.mov"
}

The id is a UUIDv5 of the path you installed from. Installing the same file again replaces its entry instead of adding a second one, and --remove with the same path finds it even if the file has gone. The two fixed IDs are phonto’s category and subcategory. Read the last twelve hex digits of each as ASCII and you get PHONTO and SUBCAT.

The video key says 240 fps. Apple’s own Aerials really are 240 fps, and on macOS 27 it’s the only video key in the whole catalog. Every phonto video goes under it, whatever its frame rate.

The file is strict, and when it breaks, it breaks somewhere else:

  • Every category needs a non-empty list of subcategories, and every asset has to name both a category and a subcategory. Leave either out and the extension rejects the entire catalog. System Settings then shows no Aerials at all, Apple’s included. We found that out the way you’d expect.
  • An empty representativeAssetID or previewImage on the category does the same. phonto won’t write an entry at all if it couldn’t make the thumbnail.
  • Spaces in the file:// URLs have to be %20. A raw space and the video won’t load.
  • Change a video under an existing UUID and the extension keeps playing the one it already loaded. phonto runs killall WallpaperAgent after every change, and macOS brings up a fresh agent and a fresh extension. On macOS 27, killing only the extension blacks out the screen. It doesn’t come back on its own. WallpaperAgent keeps asking it for the wallpaper a few hundred times a minute, and the screen stays black until you log out or kill the agent.

phonto’s category has preferredOrder: -1, which puts the Phonto section above Apple’s.

What one entry can break
"categories": [ 6 of Apple’s,{"id": "8C75F1C2-…-50484F4E544F","representativeAssetID": "previewImage": "subcategories": "localizedNameKey": "Phonto","preferredOrder": }],"assets": [ 164 of Apple’s,{"id": "CB528B3F-C8B5-58FC-BB89-8B0ED2436C9B","localizedNameKey": "harbour","url-4K-SDR-240FPS": "file:///Users/you/Library/ApplicationSupport/…/CB528B3F-….mov"}]

System Settings

7 categories, 165 assets

  1. Phonto1
  2. Dynamic Wallpapers4
  3. Landscape83
  4. Cityscape30
  5. Underwater21
  6. Earth22
  7. Mac4

phonto’s category and the harbour entry from earlier, as phonto writes them, with Apple’s six categories and 164 assets around them. The names and counts are from the catalog on our Mac. Click a value to break it.

First lock, lovely. Second lock, black.

The first lock plays the video. Unlock, then lock again, and the screen is black. Close the lid, open it, still black.

We looked the unified log. On macOS 26, the extension logs Play Called and StartReadingItem on the first lock, and the decoder starts. About 2.3 seconds of frames in, it stops. Later locks log nothing from the player at all, apart from the occasional FigVideoQueueGMStats: 0 frames enqueued in the last 6 seconds, the most honest line in the entire log.

On macOS 27 it fails differently. The player logs Play Called on every lock. The tell is VideoSampleReadingErrors Code=4, then the extension trying to re-download the video, as if a file on your own disk might have come from Apple’s CDN. The extension also plays the video in 'idle', a mode WallpaperAgent switches to after the extension starts and after every unlock. In the run below the video played there first, and the very first lock was the one that failed.

Lock after lock

installed file encoded with

Two runs from our Mac’s unified log on 9 November: a file encoded without the setting, and phonto’s 60 fps file with it. Only WallpaperAgent’s presentation-mode changes, the player’s calls and the busiest of the extension’s eight frame queues are here. Click the setting to switch runs, or a line to explain it.

These didn’t fix it:

  • The original file, copied in untouched.
  • HEVC from AVFoundation’s export presets.
  • 10-bit HEVC through AVAssetWriter. Better than 8-bit, and still black from the second lock on.
  • A keyframe every 24 frames.
  • Stripping the colour tags from the frames.

The catalog entry never needed to change. The file did.

One encoder setting

The fix is one key on the encoder, kVTCompressionPropertyKey_BaseLayerFrameRate, set to 30. It makes VideoToolbox split the stream into temporal sub-layers: a base layer of up to 30 fps, and a layer above it for the frames in between.

To check it was this key and nothing else, we took one 30 fps clip and one 60 fps clip and encoded each the way phonto does, changing nothing between encodes except that key. We copied each file over an installed video and locked the screen three times. With the setting, both clips played on all three locks. Without it, both played on the first lock and went black after that. So it is the setting. Why the player cares is still a guess.

Frames you can drop

A compressed video stores very few whole pictures, and those are the keyframes. Every other frame is stored as the changes from an earlier one. Lose a frame and you lose everything built from it, up to the next keyframe. Sub-layers sort frames by what they’re built from. Frames in the base layer are built only from each other. A frame in a higher layer can be built from its own layer and the ones under it, and nothing in a lower layer is ever built from it.

What each frame is built from
layer 1not declaredbase

One frame dropped. Four more were built from it and won’t decode until the next keyframe.

sub-layers
A keyframe, in terracotta, is a whole picture. Every other frame is stored as the changes from the frame its arrow comes from. Pick a frame to drop it. With two sub-layers the frames on top are built from the base, and nothing is built from them. A player can drop every one of them and the base still plays.

The name for this is temporal scalability. A player can drop the top layers and decode at a lower frame rate, which is handy when your source is 240 fps and your screen isn’t. Two sub-layers is the smallest stream of that shape. With the base layer at 30 fps, phonto’s encode of a 60 fps clip puts every second frame in the layer on top.

A fifth of a second, by sub-layer
240 fps10× slower
stream
Each sub-layer adds the frames between the ones under it. Pick a layer to decode only up to it, as a player can: the dot steps less often, but evenly. phonto’s 30 fps stream keeps every frame in its base layer and declares a second, empty one.

Where the setting shows up

A .mov is a tree of boxes, each one a size, a four-letter name and its contents. The frames sit in one big box, mdat. Everything that describes them sits in another, moov. The setting shows up in two places inside moov: the decoder’s configuration, and the sample groups that label each frame.

HEVC in a .mov comes with a small configuration record, the hvcC box: profile, level, bit depth, chroma format. Comparing those fields got us nowhere. The box even has a numTemporalLayers field, and VideoToolbox writes 1 there whether the stream has one temporal layer or two.

The difference is a level down, in the parameter sets that the box carries. The first of those is the video parameter set, the VPS. Here it is for the same frames, encoded by VideoToolbox with phonto’s settings, once without BaseLayerFrameRate and once with it:

text
without  40 01 0c 01 ff ff 22 20 00 00 03 00 b0 00 00 03 00 00 03 00 99       17 02 40
with     40 01 0c 02 ff ff 22 20 00 00 03 00 b0 00 00 03 00 00 03 00 99 00 00 17 02 40

The first two bytes are the NAL unit header. The next two pack six fields, and the fourth byte is where the two streams differ:

text
0x01   0000 000 1   vps_max_sub_layers_minus1 = 0   vps_temporal_id_nesting_flag = 1
0x02   0000 001 0   vps_max_sub_layers_minus1 = 1   vps_temporal_id_nesting_flag = 0

The first four bits are the end of vps_max_layers_minus1, which is 0 in both. The next three are the number of temporal sub-layers, minus one. The last bit is a nesting flag. The spec requires it to be 1 when there’s only one sub-layer, and VideoToolbox clears it once there are two. One sub-layer against two.

The two extra zero bytes come from profile_tier_level(). Once a stream has more than one sub-layer, it carries two flags for each sub-layer below the top one, saying whether that sub-layer has its own profile and level, and pads them out to sixteen bits. Both flags are 0 here. Sixteen zero bits. The 03s scattered through both lines are emulation-prevention bytes, which stop the payload from looking like a start code.

The SPS gets the same sixteen bits in its own copy of profile_tier_level(). The rest of what changes in the SPS and PPS is the encoder picking a different reference structure, not sub-layer syntax.

The sample groups are the second place. With the setting on, VideoToolbox tags every frame with its temporal level, and AVAssetWriter turns the tags into groups in the track’s sample table. tscl has one entry for each layer the frames are in, with that layer’s profile, level and frame rate, and a csgm box says which entry each frame belongs to. tsas marks the frames where a player can start decoding a higher layer. phonto’s file from a 60 fps clip has two tscl entries and a tsas. From a 30 fps clip it has one tscl entry and no tsas, because every frame is in the base layer. Without the setting no frame is tagged, and the writer writes none of them.

Apple’s Aerials have five

Apple’s catalog lists a download URL for every Aerial. We fetched the first few megabytes of twelve of them, picked across Apple’s different naming schemes, and parsed them. They all agree, and here they are next to phonto’s output:

phonto Apple’s Aerials
Frame rate the source’s 240 fps
Temporal sub-layers in the VPS 2 5
numTemporalLayers in hvcC 1 5
B-frames none yes
Time between keyframes up to 2 s about 5 s

Apple’s VPS starts 40 01 0c 08. That fourth byte is 0000 100 0, which makes vps_max_sub_layers_minus1 4. The base layer runs at 15 fps and each layer above it doubles that, to 30, 60, 120 and finally 240. Apple’s tscl has an entry for each of the five, and the files carry a tsas too.

The table also rules two things out. Apple’s files use B-frames and five-second gaps between keyframes. Whatever the player objects to, it isn’t those.

Why does the player care?

We don’t know, but we have a guess. VideoToolbox’s decoder has a documented property, kVTDecompressionProperty_TemporalLevelLimit, that makes it decode only up to a given sub-layer. That’s exactly what you’d reach for to play a 240 fps file on a 60 Hz display. A player built around it could take a different path for a stream it thinks has a single layer, one that doesn’t come back after the first lock.

The strange part is that for a 30 fps video, phonto’s setting puts every single frame in the base layer. The second layer is declared and empty. It plays across lock cycles anyway, and after the Mac sleeps and wakes.

The sub-layer count alone isn’t enough, though. We made a file that declares two sub-layers in the VPS but leaves the nesting flag at 1 and has no tscl box. It goes black from the second lock, like a file with no sub-layers at all. The player wants the flag or the box, maybe both. We haven’t tried one without the other.

The encoder

phonto’s transcoder is Rust and drives VideoToolbox’s VTCompressionSession directly. Its settings:

  • HEVC Main10, automatic level.
  • Quality-based rate control, limited to 160 Mbps averaged over 5 seconds.
  • A keyframe at least every 120 frames or 2 seconds.
  • No real-time mode, and no frame reordering, which means no B-frames.
  • An expected frame rate of 60, with a base layer of 30 and two temporal layers.
  • The source’s colour primaries, transfer function and matrix.

This is the part that matters, as it is in transcode.rs:

rust
// Without `BaseLayerFrameRate` the lock-screen player goes black after
// the first lock, even with two sub-layers declared in the VPS.
// `NumberOfTemporalLayers` is undocumented and changes nothing on
// macOS 27. It stays because macOS 26 is untested without it.
let ntl_key = CFString::from_str("NumberOfTemporalLayers");
let ntl_val = cfnum_i32(TEMPORAL_LAYER_COUNT);
let st = VTSessionSetProperty(s, &ntl_key, Some(&ntl_val));
if st != 0 {
    log::warn!("NumberOfTemporalLayers failed: OSStatus {st}, continuing without it");
}
let blfr = cfnum_f64(BASE_LAYER_FPS);
vt_set(
    s,
    kVTCompressionPropertyKey_BaseLayerFrameRate,
    &blfr,
    "BaseLayerFrameRate",
)?;

kVTCompressionPropertyKey_BaseLayerFrameRate is public and documented, and has been since macOS 10.13. Frames above that rate go into the higher layer. NumberOfTemporalLayers isn’t documented anywhere, and phonto builds the key from a string at runtime. On macOS 27, BaseLayerFrameRate alone produces the same parameter sets as both keys together, byte for byte. The undocumented key is mostly along for the ride. Set only NumberOfTemporalLayers and you get the black file from earlier, the one with two sub-layers in the VPS and no tscl box.

When phonto got this feature in May, on macOS 26, AVAssetWriter’s compression settings wouldn’t take BaseLayerFrameRate. That’s why phonto runs the encoder itself and only uses AVAssetWriter to write the file, in pass-through mode. With no output settings, the writer puts the encoder’s bytes into the .mov untouched, which is what keeps the VPS intact. On macOS 27, AVAssetWriter accepts the key and produces the same VPS. phonto stuck with its own encoder. One code path that works on both beats adding a second one for 27.

Where the setting travels

AVAssetReadersource , 10-bit 4:2:0

frame01234567891011

VTCompressionSession

VPS40 01 0c 02 ff ff …2 sub-layerslayer010101010101tag010101010101

AVAssetWriteroutputSettings nil

tscl010101010101tsas·1·1·1·1·1·1

lock screensecond lock plays

Measured on macOS 27: twelve frames of a test clip through phonto’s settings. layer is the temporal ID in each frame’s NAL unit header, and tag is the temporal level VideoToolbox attaches to the frame for the writer. A dot is a frame with nothing there. Click either setting, or the source frame rate.

phonto asks AVFoundation’s reader for 10-bit 4:2:0 frames. Handing VideoToolbox 8-bit frames to convert itself lost colour detail at every bitrate we tried, 450 Mbps included. The output keeps the source’s resolution, frame rate and colour tags. On our 4K test clips the average luma error stayed under half an 8-bit step. That costs space. Expect 125 to 250 MB for 20 seconds of typical 4K, and up to 400 MB for the noisiest. phonto writes each frame to the file as it leaves the encoder. A long clip never sits in memory whole. phonto drops the audio track.

One more thing VideoToolbox keeps to itself. Once you set a data rate limit, the quality value stops mattering, unless it’s 1.0, in which case the limit stops mattering instead. We found out by asking for better quality and getting the exact same numbers back.

What it still can’t do

A macOS update can take your wallpaper away. When Apple ships a new Aerials catalog, as it did with macOS 27, macOS replaces the whole manifest folder. phonto’s entries go with it, and so does the entries.json.phonto-backup copy phonto keeps there. After an update, run install-live-lockscreen again, pick the video again in System Settings, and log out and back in. That’s what brought it back after the update to macOS 27. A plain install on 27 doesn’t need the log out, because phonto restarts WallpaperAgent itself, but we haven’t tried leaving it out after an update.

It’s untested on Intel Macs, because neither of us has one, and nobody has fed it an HDR video yet. We don’t know the oldest macOS it works on either. We know the catalog sits in this place on macOS 26 and 27.

The details for running it are on the lock screens page. Nature loves to hide, and so, it turns out, does whatever the player is actually reading.

Further reading

This post skips a lot of background. If video is new to you, the first group covers enough to follow most of it. The rest goes deeper, in roughly the order the post does.

How video compression works

  • Digital video introduction, Leandro Moreira. Starts from an image as a grid of numbers and builds up to colour, chroma subsampling, I, P and B frames, motion prediction and the transform. Start here.
  • H.264 is Magic, Sid Bala. A shorter, friendlier tour of why raw video is enormous and how a codec shrinks it. The original site is down, and this is an archived copy.
  • A Digital Media Primer for Geeks, Xiph.Org. A video on sampling, bit depth, colour, chroma subsampling and frame rates, the vocabulary under everything here.
  • Why snow and confetti ruin YouTube video quality, Tom Scott. Four minutes on what happens to a codec that predicts each frame from the last when nothing is predictable.
  • I, P, and B-frames, Krishna Rao Vijayanagar. Keyframes, groups of pictures, and why B-frames mean a file stores frames in a different order from the one you see.
  • What is a codec, and how does it differ from a file format?, slhck’s answer on Super User. Why HEVC, .mov and VideoToolbox are three different things: a codec, a container and an encoder.

Bit depth, colour and bitrate

  • Digital video concepts, MDN. Why video stores brightness and colour separately, and what 4:2:0 throws away.
  • What every coder should know about gamma, John Novak. Why a transfer function exists, and the banding you get with too few bits per sample. Banding is part of why phonto works in 10-bit.
  • Color Spaces, Bartosz Ciechanowski. An interactive essay on primaries, white points and transfer curves, which is what the colour tags on each frame describe.
  • Color Me Intrigued, Vittorio Giovara, Demuxed 2017. A talk on primaries, matrices and transfer functions in video, and what breaks when the tags are wrong.
  • H.273, ITU-T. The numbered tables of primaries, transfer functions and matrices that HEVC, .mov files and ffmpeg all refer to. Free.
  • Understanding rate control modes, Werner Robitza. Constant quality against bitrate targets and caps. It’s written for x264 and x265, and the ideas behind VideoToolbox’s quality value and data rate limit are the same.
  • Digital Video and HD: Algorithms and Interfaces, Charles Poynton, 2012. The reference book for everything in this group. Paid.

Inside an HEVC stream

Looking at the bytes yourself

  • FFmpeg’s trace_headers filter prints every field of the VPS, SPS and PPS under its name in the spec. ffmpeg -i clip.mov -c:v copy -bsf:v trace_headers -f null - runs it over a file.
  • Bento4’s mp4dump prints the hvcC box, numTemporalLayers included, and lists the sample groups. A file with temporal layers has a tscl among them.
  • h265nal prints each NAL unit header and parameter set as a tree. It reads raw HEVC. Pull the stream out of the .mov first with ffmpeg -i clip.mov -c:v copy clip.h265.

Apple’s side

Other projects in the same catalog

written by