Take a picture of a screen

This guide shows you how to capture what a monitor shows: one frame as PNG or JPEG, a clip as MP4, a live MJPEG stream, or a rectangle of any of those. You need the operator installed on your liken cluster and a connected Display .

display-api serves the captures from a Deployment in liken-system. The capture container in the display-operator pod on each node reads frames from the compositor. The API stores no capture. It creates each capture after you ask for it and streams the result while it encodes it. The API reference has the full contract. This guide is the short path through it.

The kubectl liken display capture command

The short path is the CLI. liken plugins sync installs the kubectl liken display plugin, as Install the plugins describes. kubectl liken display capture streams an output’s screen to stdout as MP4, so a file or a pipe is a single command:

kubectl liken display capture lg-hdr-wqhd-display | mpv -
kubectl liken display capture lg-hdr-wqhd-display --format png > screen.png

--format png writes one frame in place of a clip. A Display is cluster-scoped, so the command takes no namespace.

The CLI compares its own version with the operator’s. When they differ, it warns on stderr and tells you to run kubectl liken plugins sync. --force silences that warning and the capture runs either way. --version prints the CLI’s version and exits.

The CLI authenticates with the client certificate in your kubeconfig, the same subject kubectl uses, so the grant that step 1 describes is all it needs. It opens its own port-forward to display-api and reads the stream through it, so it needs no in-cluster routing and runs from a laptop.

The output argument completes to the names the cluster reports. kubectl runs the plugin’s completion shim on its own, so kubectl liken display capture <TAB> lists the Displays. For a direct call to kubectl-liken-display, load the script with source <(kubectl liken display completion bash).

The numbered steps below are the HTTP contract the CLI calls, for an application in the cluster, a still through a port-forward, or a JPEG or MJPEG capture the CLI does not serve.

1. Who may capture

You can identify yourself with a client certificate or with a Bearer token. The API checks for a certificate first, then for a token, in the same order as the Kubernetes API server.

If your connection presents a client certificate signed by the cluster’s own certificate authority, you are that certificate’s subject. Your user name is the subject’s common name, and your groups are its organization values. The credentials in your kubeconfig identify you here the same way they identify you to kubectl. A certificate from any other authority ends the TLS handshake.

If you present no certificate, send a Bearer token. The API verifies it with a TokenReview for the audience display-api, and checks that the answer names that audience. A pod’s ordinary API server token does not have that audience, so it does not work here.

After it identifies you, the API sends a SubjectAccessReview for the verb get on displays/screen in the group display.liken.sh. Every route authorizes before it reads anything, so a 403 never tells you whether a name exists.

The operator ships a ClusterRole named display-capture-viewer and binds it to nobody. It grants get on displays/screen for the capture routes and get on displays for the info route. Read your own subject from your kubeconfig:

kubectl config view --raw --minify \
  -o jsonpath='{.users[0].user.client-certificate-data}' \
  | base64 -d | openssl x509 -noout -subject

Then bind the role to the common name that command printed:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: display-viewer
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: display-capture-viewer
subjects:
  - kind: User
    name: <the common name>

To bind a group instead, use kind: Group with one of the certificate’s organization values as the name.

An application gets the grant through its ServiceAccount:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: display-viewer
  namespace: liken-system
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: display-viewer
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: display-capture-viewer
subjects:
  - kind: ServiceAccount
    name: display-viewer
    namespace: liken-system

A role that grants resources: ["*"] on display.liken.sh already includes displays/screen, and so does cluster-admin. Every subject with one of those may look at every screen. pods/exec in liken-system is also a capture grant: the display-operator pod shares its process namespace between its containers, so a shell in that pod can reach the capture socket.

Every request that returned bytes writes a Captured Event on the Display, with the subject and the aspect in its message.

2. Reach the API

display-api is a ClusterIP Service at https://display-api.liken-system.svc. It serves HTTPS with its own certificate authority. That authority’s certificate is in the ConfigMap display-api-ca in liken-system, under the key ca.crt.

A port-forward is fine for a still and bad for a stream. It is a single TCP connection through the API server. In our tests it moved about 2 Mbit/s: a 3 second MJPEG stream that the node captured in 3.06 s took 25 s to read through the forward, and 3.09 s from a pod on the cluster network. A 1080p MJPEG stream needs about 15 Mbit/s, so through the forward you see it at about one eighth of real time. Stills are not affected. screen.png took 0.9 s to first byte through the forward and 0.76 s from the cluster network. Read a stream from a pod on the cluster network.

Put your client certificate and its key in a Secret that the pod can mount:

kubectl config view --raw --minify \
  -o jsonpath='{.users[0].user.client-certificate-data}' | base64 -d > client.crt
kubectl config view --raw --minify \
  -o jsonpath='{.users[0].user.client-key-data}' | base64 -d > client.key
kubectl -n liken-system create secret tls display-client \
  --cert client.crt --key client.key

Write the pod to capture-pod.yaml:

apiVersion: v1
kind: Pod
metadata:
  name: capture
  namespace: liken-system
spec:
  restartPolicy: Never
  containers:
    - name: curl
      image: curlimages/curl:8.22.0
      command: [sleep, "3600"]
      volumeMounts:
        - name: ca
          mountPath: /ca
          readOnly: true
        - name: client
          mountPath: /client
          readOnly: true
  volumes:
    - name: ca
      configMap:
        name: display-api-ca
    - name: client
      secret:
        secretName: display-client

The pod is in liken-system because a volume can only read a ConfigMap or a Secret from the pod’s own namespace. The pod sleeps for an hour and then exits, so a pod you forget does not run forever.

kubectl apply -f capture-pod.yaml
kubectl -n liken-system wait --for=condition=Ready pod/capture --timeout 60s

An application needs no Secret. It runs as the ServiceAccount you bound in step 1, mounts a token for the API’s audience, and sends it as Authorization: Bearer:

volumes:
  - name: token
    projected:
      sources:
        - serviceAccountToken:
            audience: display-api
            expirationSeconds: 3600
            path: token

For one still and nothing more, you need no pod. A port-forward is a TCP tunnel, so the TLS handshake runs end to end and the certificate reaches the API unchanged:

kubectl -n liken-system port-forward svc/display-api 8443:443 &
kubectl -n liken-system get configmap display-api-ca \
  -o jsonpath='{.data.ca\.crt}' > display-api-ca.crt
curl --cert client.crt --key client.key --cacert display-api-ca.crt \
  -o screen.png \
  https://localhost:8443/v1/display/displays/lg-hdr-wqhd-display/screen.png

3. Take the capture

List the screens and pick one:

kubectl get displays

Every command below runs in the pod from step 2 and writes its file there. --fail-with-body makes curl exit non-zero on an error and still write the problem document, which step 4 reads.

One frame as PNG:

kubectl -n liken-system exec capture -- curl -sS --fail-with-body \
  --cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
  -o /tmp/screen.png \
  https://display-api.liken-system.svc/v1/display/displays/lg-hdr-wqhd-display/screen.png

One frame as JPEG, at quality 95:

kubectl -n liken-system exec capture -- curl -sS --fail-with-body \
  --cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
  -o /tmp/screen.jpg \
  'https://display-api.liken-system.svc/v1/display/displays/lg-hdr-wqhd-display/screen.jpg?quality=95'

A ten second clip, as H.264 in fragmented MP4:

kubectl -n liken-system exec capture -- curl -sS --fail-with-body \
  --cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
  -o /tmp/clip.mp4 \
  'https://display-api.liken-system.svc/v1/display/displays/lg-hdr-wqhd-display/screen.mp4?t=,10'

Three seconds of MJPEG, one JPEG part per frame:

kubectl -n liken-system exec capture -- curl -sS --fail-with-body \
  --cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
  -o /tmp/stream.mjpeg \
  'https://display-api.liken-system.svc/v1/display/displays/lg-hdr-wqhd-display/screen.mjpeg?t=,3'

The top left quarter of a 1920x1080 screen, as PNG:

kubectl -n liken-system exec capture -- curl -sS --fail-with-body \
  --cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
  -o /tmp/corner.png \
  'https://display-api.liken-system.svc/v1/display/displays/lg-hdr-wqhd-display/screen.png?xywh=0,0,960,540'

The query parameters

Parameter What it does
t= A W3C Media Fragments time range in seconds. Zero is the instant the capture container accepts the request. t=,10 records ten seconds from now. t=5,7 discards five seconds and then records two. t=5 on a still waits five seconds and takes one frame. A begin over 60 seconds is a 400, and a t= end on a still is a 400. Without an end, a clip or a stream runs until you close the connection.
xywh= A rectangle of the frame as x,y,width,height, in the frame’s own physical pixels. xywh=percent:0,0,50,50 is the same rectangle in percent. A region that runs off an edge is clipped to the screen. An origin at or past an edge is a 400.
width=, height= Scale the region down after the crop, with the aspect ratio kept. Both together, or a value larger than the source, is a 400.
framerate= Frames per second of a clip or an MJPEG stream. 15 by default, at most the output’s refresh rate. A 400 on a still.
quality= JPEG quality from 1 to 100. 85 by default. A 400 on PNG and MP4.

The extensions are .png, .jpg, .mp4, and .mjpeg. A path with no extension negotiates on Accept and returns image/png if you send none.

4. Check what you got

Copy a file out of the pod and read it with ffprobe:

kubectl -n liken-system cp capture:/tmp/clip.mp4 clip.mp4
ffprobe clip.mp4

A clip is H.264 in mov,mp4,m4a,3gp,3g2,mj2, at the size of the screen. A still is one png or mjpeg frame. A capture with xywh= has the size of the region.

The cluster’s own record of the capture is an Event. A Display is cluster-scoped, so its Events are in the default namespace, the same convention a Node’s Events follow:

kubectl get events --field-selector reason=Captured

Each message names the subject, the aspect, and the media type, so kubectl describe display tells you who looked at a screen and when.

When a capture is refused

Every error is an application/problem+json document with type, title, status, detail, and instance. curl wrote it to the output file, so read that file:

kubectl -n liken-system exec capture -- cat /tmp/screen.png
Status What it means What to do
401 No client certificate and no token, or the TokenReview refused the token Check that the Secret has the certificate and key from the kubeconfig you use, or mint a token for the audience display-api
403 The SubjectAccessReview said no Bind display-capture-viewer to your subject, as step 1 shows. The WWW-Authenticate header names the scope you need
503 The screen has no node (no-node), its compositor is not serving (compositor-down), the output is already being captured (capture-busy), or the capture container is absent or not ready (upstream-failed) The response has Retry-After: 5. Wait five seconds and try again. detail quotes the source of the error

You can ask what a Display is even when its screen is down. The info route returns the name, the node, and the size and refresh rate the Display reports, with compositor: down or sidecar: unreachable and the condition’s message in detail:

kubectl -n liken-system exec capture -- curl -sS --fail-with-body \
  --cacert /ca/ca.crt --cert /client/tls.crt --key /client/tls.key \
  https://display-api.liken-system.svc/v1/display/displays/lg-hdr-wqhd-display

5. Clean up

kubectl -n liken-system delete pod capture
kubectl -n liken-system delete secret display-client
rm client.crt client.key

The ClusterRoleBinding from step 1 is a standing grant. If the capture was a one-off, delete it too:

kubectl delete clusterrolebinding display-viewer

What a capture costs the screen

A capture turns off the compositor’s hardware planes for its whole length. A film that a plane would normally show goes through the GL renderer instead while a clip runs. On a node whose driver has no VA-API post-processing, the color conversion runs on the CPU too, which costs about four times the cores at 1080p. The info route’s conversion field names the pipeline the node uses.