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.