Use

Photos

On this page

This page is for the person who decides which photos a frame shows. It covers all three ways to get photos onto a frame, how the frame picks each day's set, and how the server protects itself when it opens pictures that strangers send. When you finish, a frame is showing photos from the source you chose.

Choose a method

A frame's Photos widget has one source at a time. Pick one:

Method 1: Nextcloud album Method 2: photo library Method 3: family drop links
Where photos live Your Nextcloud Your frame server Your frame server, in a library album
Who adds photos Anyone who can add to the Nextcloud album You, from your computer Relatives, from their phones, with your approval
Extra server software None GD, and ideally a sandbox. HEIC support needs libheif. The same as method 2
Needs the fetcher Yes No No
Setup effort A few minutes An album and a decoder check Method 2, plus a link per person

Methods 2 and 3 share the library. Drop links add photos to a library album, so you need method 2 first.

You can use methods 1 and 2 on different frames. You can also switch a frame between them. The frame keeps the Nextcloud link while it points at the library, and finds it again when you switch back.

How a frame picks its photos

The frame shows a daily set. Understanding the rules explains why a new photo may not show up straight away.

flowchart LR
    A["Album<br/>approved photos only"] --> P["Pick<br/>once a day, at the pick time"]
    P --> S["Daily set<br/>up to 50 photos"]
    S --> D["Frame downloads<br/>and keeps them"]
    D --> V["Screensaver<br/>and Pictures page"]
  • 50 photos a day. If the album has 50 photos or fewer, the frame shows all of them every day.
  • The newest first. Up to 25 of the set are the newest photos added in the last 30 days.
  • No repeats until the album is used up. The rest are chosen at random from photos this frame has not shown in the current pass through the album. When none are left, a new pass begins.
  • Approved photos only. Photos waiting for your approval never reach a frame.
  • Once a day. The set is fixed until the next pick time. Photos you add after the pick can appear in the next day's set.
  • Pick time. Each frame gets its own time between 04:00 and 06:00 in its time zone, chosen so no two frames pick at the same moment. You can change it with New photos daily at. If another frame already uses that exact minute, the admin says "Another frame uses this exact time. Move it a minute earlier or later."
  • Kept on the frame. The frame downloads each photo (up to 4 MB each) and stores it. They keep showing when the network or the photo host is down. The frame skips downloads while it has less than 200 MB free.
  • Where they show. On the screensaver, which starts after the idle time you set under Schedule, and on the Pictures page.

In the admin, the frame's Photos section shows the day's picks as a contact sheet: New today and Carried over, with the time of the next pick.

Setting a different source or album for a frame starts a new pass and a new pick.

Method 1: A Nextcloud public album

The Photos widget shows a public Nextcloud Photos album. The fetcher on your server keeps a list of the album's photos. The frame loads the images from Nextcloud itself.

What you need

  • A Nextcloud with the Photos app.
  • The Nextcloud host in photo_hosts in config.php, for example 'cloud.example.org'. See Install the frame server.
  • The fetcher cron job running. See Install the frame server.
  • A Nextcloud that the frame can reach. The frame loads previews straight from the Nextcloud host, so it must be reachable from the frame's network.

Set it up

  1. In Nextcloud, share the album with a public link and copy the link. It looks like https://cloud.example.org/apps/photos/public/<token>. A link with /index.php/apps/photos/public/<token> also works.
  2. In the admin, open the frame, then Widgets, switch Photos on, and select Save.
  3. Open the Photos section.
  4. Under Photos come from, choose Nextcloud link.
  5. Paste the link into Album link.
  6. Check New photos daily at, and Show each photo for.
  7. Select Save.

Under Album link the admin shows a status: the number of photos once the fetcher has run, Not fetched yet until then, or an error message.

The link is stored on the server and never shown again. The field shows "A link is saved. Paste a new one to replace it." Use Remove link to delete it, or Keep link to cancel the removal.

What it uses

  • The fetcher lists the album over WebDAV and refreshes the list about every 15 minutes.
  • It keeps up to 2000 photos from an album.
  • It sees JPEG, PNG, and HEIC or HEIF files. Other file types in the album are ignored.
  • The frame loads 1280x800 previews from Nextcloud.
  • The photos' "newest" order uses when the server first saw each photo in the album.

Method 2: The photo library

The photo library keeps photos on your server instead of on Nextcloud. You make albums, add photos from your computer, and point a frame at an album.

The server keeps only frame-sized copies. Each photo is shrunk to fit 1280x800 (never enlarged), its metadata (location, camera, date) is removed, and a 320x200 thumbnail is made. The original is not kept.

You can skip the library if you use Nextcloud only. It is off until its host requirements are met, and nothing else on the server depends on it.

What the server needs

Image decoding is the risky part of any upload feature. The server decodes in a separate, limited process, and refuses anything but JPEG, PNG, WebP and HEIC. It needs:

  • PHP's GD extension for the command-line PHP, with JPEG, PNG and WebP support (package php8.5-gd; a plain php-gd can pull in another PHP version's GD). Check:

    php -n -d extension=gd -r 'print_r(gd_info());'
    

    It must show JPEG Support, PNG Support and WebP Support as 1.

  • For iPhone (HEIC) photos: libheif's command-line converter and its HEVC decoder:

    sudo apt install libheif-examples libheif-plugin-libde265
    heif-dec --version || heif-convert --version
    

    heif-convert, the older name of the same tool, also works. Ubuntu 24.04 (libheif 1.17) ships only heif-convert. Without them everything else still works and HEIC uploads are refused.

  • prlimit (package util-linux) and nice (package coreutils). Both are on most Linux servers.

  • A PHP command-line binary. The worker runs php, not the web server's PHP. The server looks for the one running this command, then /usr/bin/php8.5, /usr/bin/php, /usr/local/bin/php. If yours is elsewhere, set 'php_binary'. See Limits and config keys.

  • A sandbox, unless you choose to run without one. See Sandbox modes.

  • Minimum versions of the libraries that read untrusted images. Older ones have known memory-safety bugs a hostile image can reach. Debian and Ubuntu fix them under the old version number, so a lower number can still be patched. Keep security updates on.

    Library Minimum
    libheif 1.17.6 (Ubuntu 24.04 ships it in main)
    libde265 A version with the current security fixes. Keep updates on.
    libwebp 1.3.2
    libjpeg, libpng (inside PHP's GD) Current distribution security updates.
  • If php_binary or heif_dec points outside /usr, its installation prefix (the directory two levels up, for example /opt/php for /opt/php/bin/php) is bound read-only into the bubblewrap sandbox. That prefix must not contain secrets.

bin/migrate.php checks all of this. See Troubleshooting.

Why no ImageMagick

The library does not use ImageMagick or php-imagick. On Ubuntu 24.04, ImageMagick and php-imagick sit in the universe repository, and universe gets security updates only with Ubuntu Pro. The decoders the library uses instead (libheif, libde265, libwebp, GD) come from main, which gets free security updates.

Check where a package lives with apt-cache policy libheif-examples. The line for your release says main or universe.

If nothing else on the host needs ImageMagick, sudo apt remove --simulate php8.5-imagick shows what you could remove.

How the decoder works

Every upload goes through the same pipeline, from the admin or from a drop link:

flowchart TD
    U["Upload<br/>raw body, up to 30 MiB"] --> SP["Spool to var/photos/tmp<br/>hash as it arrives"]
    SP --> SN["Sniff the first bytes<br/>JPEG, PNG, WebP or HEIC only"]
    SN --> CK["Check limits<br/>album full, library full, disk space"]
    CK --> WK["Worker process<br/>sandbox + prlimit + 45 s kill"]
    WK -->|"HEIC"| HD["heif-dec converts to PNG<br/>same sandbox"]
    HD --> WK
    WK --> OUT["Frame JPEG 1280x800<br/>thumbnail 320x200<br/>no metadata"]
    OUT --> ST["Store by content hash<br/>var/photos/files"]
    ST --> DB["Record in the database<br/>approved or pending"]
  • The server decides the file type from the first bytes, not from the file name. SVG, AVIF and similar are refused.
  • JPEG, PNG and WebP are decoded by PHP's GD in a separate process with no network. With a sandbox on, it sees only system libraries and its scratch folder. It runs under prlimit (1 GiB of address space, 60 CPU seconds, 32 open files), nice, and a wall-clock kill after 45 seconds.
  • Image sizes are read from the file headers first. Anything over 12,000 pixels on a side or 64 megapixels is refused before decoding.
  • HEIC takes three steps in the same sandbox: a header check, heif-dec converting to a PNG in the scratch folder (it applies the photo's rotation itself), and GD reading that PNG.
  • Each decoder step is killed after 45 seconds. A whole HEIC upload (header check, conversion and resize) shares one 60-second deadline.
  • The photo's EXIF orientation is applied to JPEGs.
  • Photos are always saved as plain sRGB baseline JPEGs with no metadata. Color profiles are not converted. A Display P3 photo (iPhone) is shown as sRGB and looks slightly less saturated.
  • CMYK JPEGs are refused (unsupported_color: export as sRGB), and so are animated WebPs (unsupported_animation). Cut-off JPEG files are refused.

Sandbox modes

The decoder runs a program on files that strangers can send. The sandbox limits the damage if that program has a bug. You choose the mode with the sandbox option under widgets > photos in config.php. There are four modes.

Mode Setting Use it when
bubblewrap 'sandbox' => 'bwrap' (the default) bwrap works on your server. It is the simplest strong option.
Dedicated user 'sandbox' => 'user' bwrap does not work, for example on Ubuntu 24.04. Recommended there.
Automatic 'sandbox' => 'auto' You want bwrap when it works and no sandbox otherwise. The migration prints a NOTE when it falls back. Not for drop links.
None 'sandbox' => 'none' You are the only one who uploads, and you accept the risk. Not for drop links.

With a mode that is required (bwrap or user) and not working, uploads are refused with sandbox_unavailable. They are never run unprotected. Frames keep showing photos already stored.

Sandbox mode bwrap: bubblewrap

bwrap is the default. Install it:

sudo apt install bubblewrap

The image worker then runs in its own namespaces with no network. It sees only system libraries, its own code and one scratch folder. It cannot see config.php, frames.db or sessions.

When bwrap works

It works where the kernel lets unprivileged programs create user namespaces. Most Debian and Ubuntu 22.04 servers allow it.

When Ubuntu 24.04 blocks it

Ubuntu 24.04 turns unprivileged user namespaces off for programs without an AppArmor profile (kernel.apparmor_restrict_unprivileged_userns=1). bwrap then fails with setting up uid map: Permission denied.

To tell whether this is your problem:

  1. Run the migration and read the output:

    sudo -u www-data php /srv/frame/app/bin/migrate.php
    

    A line like WARNING (photos): photo uploads are off: the bubblewrap sandbox is required ... but cannot run here means bwrap is not usable.

  2. Check the kernel setting:

    sysctl kernel.apparmor_restrict_unprivileged_userns
    

    = 1 means the restriction is on. The setting does not exist on older kernels.

  3. Look in the PHP error log for frame photos: worker failed. The line includes the first 300 characters of the error, which would hold uid map.

Instead of loosening the system for every program, use the dedicated-user mode on Ubuntu 24.04.

The result of the bwrap self-test is remembered for 10 minutes if it fails and an hour if it works. After you fix something, delete /srv/frame/var/photos/.sandbox-state and .probe-ok to check again at once.

Sandbox mode user: a dedicated decoder account

Instead of namespaces, run the decoder as a separate, powerless Unix user. Set 'sandbox' => 'user' under widgets > photos in config.php, and do the one-time setup below as root (sudo -i).

What the setup creates:

  • A system user framephoto (no login shell, no home, not in the web user's group) and a group photowork that the web user joins. The group is used for one thing only: the per-upload work folder (mode 2770) and the uploaded file (0640) under var/photos/tmp/.
  • A root-owned script, /usr/local/bin/frame-photo-run (copied from app/widgets/photos/bin/), which is the only thing the web user may run as framephoto. It accepts nothing but a time limit (1 to 45 seconds) and paths inside one per-upload folder (32 hex characters under var/photos/tmp/). It runs the PHP worker or the HEIC converter with fixed programs, under prlimit and timeout -k 2 -s KILL. One call runs at a time (a lock file, /etc/frame-photo-run.lock). When it ends, or is stopped, it kills every process still owned by framephoto, so a hijacked decoder cannot leave a daemon behind. Where PHP, the worker and var/ are comes from /etc/frame-photo-run.conf (root-owned KEY=/path lines; parsed, never run as shell).
  • A sudoers rule with no wildcards: www-data ALL=(framephoto) NOPASSWD: /usr/local/bin/frame-photo-run.
  • ACLs: framephoto may walk through var/ and var/photos/ (execute only: it cannot list or read them), and the photowork group may walk through var/photos/tmp/. It may read only the photo widget's own files in the app (app/widgets/photos/), through an ACL (plus a default ACL for files a later deploy adds), so your deploy permissions stay unchanged. It cannot read config.php, frames.db, sessions, stored photos or other uploads in progress.
  • A firewall rule that rejects every connection made by framephoto (IPv4 and IPv6, loopback included), kept across reboots. The script never installs or removes a firewall manager. With ufw active, the rule goes into ufw's own before.rules and before6.rules (before its loopback accept) and ufw is reloaded. If netfilter-persistent is already installed, it saves the iptables rule. Otherwise a small systemd unit (frame-photo-firewall.service) inserts it at boot. The server tests this: its self-test makes the decoder user try to connect to a port the web side listens on, and anything but a refusal turns uploads off.

If any part is missing, uploads are refused (sandbox_unavailable) rather than run unprotected, and bin/migrate.php says what failed. Change the paths in the first lines of the script if yours differ. You can run the script again safely.

sh
#!/bin/sh
# Run as root (sudo -i). Safe to run again. Paths are the only thing to adjust.
set -eu
APP=/srv/frame/app
VAR=/srv/frame/var
PHP=/usr/bin/php8.5
HEIF=/usr/bin/heif-dec   # /usr/bin/heif-convert on Ubuntu 24.04 (libheif 1.17 has no heif-dec)
WEB=www-data

# 1. packages: sudo, ACL tools, util-linux (flock, runuser), iptables, and the HEIC converter. This never installs or removes a firewall manager.
export DEBIAN_FRONTEND=noninteractive
apt-get install -y sudo acl util-linux iptables libheif-examples libheif-plugin-libde265

# 2. the decoder user and the shared group (the web user joins the group; the decoder user is not in the web user's group)
getent group photowork >/dev/null || groupadd --system photowork
id framephoto >/dev/null 2>&1 || useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin --gid photowork framephoto
id -nG "$WEB" | tr ' ' '\n' | grep -qx photowork || usermod -aG photowork "$WEB"

# 3. the wrapper (root-owned, so the web user cannot change what runs as framephoto), its config and its lock file
install -o root -g root -m 0755 "$APP/widgets/photos/bin/frame-photo-run" /usr/local/bin/frame-photo-run
cat > /etc/frame-photo-run.conf <<CONF
VAR_DIR=$VAR
APP_DIR=$APP
PHP=$PHP
HEIF_DEC=$HEIF
CONF
chown root:root /etc/frame-photo-run.conf
chmod 0644 /etc/frame-photo-run.conf
[ -e /etc/frame-photo-run.lock ] || : > /etc/frame-photo-run.lock
chown root:root /etc/frame-photo-run.lock
chmod 0644 /etc/frame-photo-run.lock

# 4. sudoers: exactly one command, nothing else (checked with visudo before it is installed)
tmp=$(mktemp)
cat > "$tmp" <<SUDO
Defaults!/usr/local/bin/frame-photo-run env_reset
$WEB ALL=(framephoto) NOPASSWD: /usr/local/bin/frame-photo-run
SUDO
visudo -cf "$tmp"
install -o root -g root -m 0440 "$tmp" /etc/sudoers.d/frame-photo
rm -f "$tmp"

# 5. directories. framephoto may walk through var/ and var/photos (execute only: it cannot list or read them) and sees nothing but the
#    per-run directories it is handed inside var/photos/tmp. It may read ONLY the photo widget's own files in the app (not the rest of
#    the app, not config.php): an ACL, so the deploy permissions (owner:www-data, 0640) stay as they are. The default ACL gives files
#    that a later deploy creates the same access.
varGroup=$(stat -c %G "$VAR")
install -d -o "$WEB" -g "$varGroup" -m 0750 "$VAR/photos"
install -d -o "$WEB" -g photowork -m 0710 "$VAR/photos/tmp"
setfacl -m u:framephoto:x "$VAR" "$VAR/photos"
setfacl -R -m u:framephoto:rX "$APP/widgets/photos"
setfacl -R -d -m u:framephoto:rX "$APP/widgets/photos"
# every directory above the widget must be passable by framephoto (execute only)
d=$APP/widgets
while [ "$d" != / ]; do
    runuser -u framephoto -- test -x "$d" || setfacl -m u:framephoto:x "$d"
    d=$(dirname "$d")
done

# 6. firewall: the decoder user may not open any network connection (IPv4 and IPv6; loopback included), and the rule survives a reboot.
#    An existing firewall is never replaced: ufw gets the rule in its own before-rules, netfilter-persistent (if already there) saves
#    the iptables rule, and anything else gets a small systemd unit.
RULE='-m owner --uid-owner framephoto -j REJECT'
apply_rule() {
    for ipt in iptables ip6tables; do
        "$ipt" -C OUTPUT $RULE 2>/dev/null || "$ipt" -I OUTPUT 1 $RULE
    done
}
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q '^Status: active'; then
    for f in /etc/ufw/before.rules /etc/ufw/before6.rules; do
        grep -q 'uid-owner framephoto' "$f" || sed -i '0,/^-A ufw-before-output/s//-A ufw-before-output '"$RULE"'\n-A ufw-before-output/' "$f"
    done
    ufw reload
elif dpkg -s netfilter-persistent >/dev/null 2>&1; then
    apply_rule
    netfilter-persistent save
else
    apply_rule
    cat > /etc/systemd/system/frame-photo-firewall.service <<UNIT
[Unit]
Description=Block network access for the photo decoder user
Before=network-pre.target
Wants=network-pre.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/sh -c 'for t in iptables ip6tables; do \$t -C OUTPUT $RULE 2>/dev/null || \$t -I OUTPUT 1 $RULE; done'
[Install]
WantedBy=multi-user.target
UNIT
    systemctl daemon-reload
    systemctl enable frame-photo-firewall.service
fi

# 7. apply the new group membership to the running web server / PHP
systemctl restart apache2 2>/dev/null || true
systemctl restart php8.5-fpm 2>/dev/null || true

echo
echo "Now add  'widgets' => ['photos' => ['sandbox' => 'user']]  to config.php, then run the checks below."

Which firewall branch runs:

Your server has What the script does
ufw, active Adds the reject rule to /etc/ufw/before.rules and /etc/ufw/before6.rules, then runs ufw reload.
netfilter-persistent installed Adds the rule with iptables and ip6tables, then runs netfilter-persistent save.
Neither Adds the rule, then installs and enables frame-photo-firewall.service, which adds the rule again at every boot.

Then add the setting to config.php:

php
'widgets' => [
    'photos' => [
        'sandbox' => 'user',
    ],
],

Then run the checks:

sh
# Checks (as root). Each line should say what its comment says.
set -u
id framephoto                                                         # uid=... gid=photowork, not in www-data
id -nG www-data                                                          # includes photowork
visudo -c                                                             # parsed OK
sudo -u www-data sudo -n -l | grep frame-photo-run                       # the one allowed command
sudo -u www-data sudo -n -u framephoto id                                # must FAIL: sudo only runs the wrapper
sudo -u framephoto cat /srv/frame/var/frames.db 2>&1 | head -1                 # Permission denied
sudo -u framephoto ls /srv/frame/var/photos 2>&1 | head -1                     # Permission denied (it can pass through, not list)
sudo -u framephoto cat /srv/frame/app/../config.php 2>&1 | head -1             # Permission denied
find /srv/frame -type f -exec runuser -u framephoto -- test -r {} \; -print 2>/dev/null   # ONLY files of app/widgets/photos (nothing in var/, cache/, config.php; slow with many photos)
find /run /var/run -type s -perm -o+w 2>/dev/null                     # unix sockets anyone can write to: only informational, review them
iptables -S | grep -- "--uid-owner $(id -u framephoto)"                                         # REJECT rule present (ufw: in the ufw-before-output chain)
ip6tables -S | grep -- "--uid-owner $(id -u framephoto)"                                        # REJECT rule present
sudo -u www-data php /srv/frame/app/bin/migrate.php                               # "schema at ..." and no WARNING (photos) lines

The find ... -print line must list only files under app/widgets/photos/. The socket line is a report, not a pass or fail: any socket listed there is something the decoder user could talk to if it were hijacked, so know what it is.

Notes:

  • The web user's new group is picked up when the web server (or PHP-FPM) restarts. The script restarts Apache and PHP-FPM for you.
  • Deploying: rsync (without -A) leaves the ACLs of existing files alone, and files it creates in app/widgets/photos/ inherit the default ACL from the folder. So framephoto keeps read access after an update, including with --chmod=D2750,F0640 (the ACL mask follows the group bits, which already allow read). Run the find ... -print check after a deploy if you want proof. If a release changes app/widgets/photos/bin/frame-photo-run, install the new copy: sudo install -o root -g root -m 0755 /srv/frame/app/widgets/photos/bin/frame-photo-run /usr/local/bin/frame-photo-run.
  • Run the script again after changing php_binary or heif_dec. In this mode the programs come from /etc/frame-photo-run.conf, not from config.php.
  • Once this works you can remove bubblewrap (sudo apt remove bubblewrap). The user mode does not use it.

Sandbox mode auto

'sandbox' => 'auto' uses bwrap when it works and falls back to no sandbox when it does not. The migration prints a NOTE when it falls back. The admin album page also says "Image processing runs without the extra sandbox." The same advice as none applies when it falls back.

Sandbox mode none

'sandbox' => 'none' is an explicit opt-out. The worker runs without a sandbox. The migration prints:

NOTE (photos): widgets.photos.sandbox is 'none': the image worker runs without the bubblewrap sandbox (resource limits still apply)

What stays: the prlimit limits, nice, the 45-second kill, the header checks and size caps, PHP's open_basedir, and a PHP configuration for the worker that turns off exec, proc_open, network functions and other dangerous functions.

What goes: the wall around the process. If a hostile image exploited a bug in GD or libheif, the exploit would run as the web user, which can read config.php and frames.db.

Use none only when you alone upload photos, from your own computer, and you do not use drop links. Do not use it when strangers or relatives can send photos.

Limits and config keys

All keys go under widgets > photos in config.php. The integer limits are all positive whole numbers.

Key Default What it limits
library_bytes 5 GiB All stored photos together.
album_photos 5000 Photos in one album, including those waiting for approval.
upload_bytes 30 MiB One photo. Keep the .htaccess LimitRequestBody (31457280) in step.
pending_album 500 Photos waiting for approval, per album.
pending_bytes 512 MiB Photos waiting for approval, all albums together.
min_free_bytes 1 GiB Uploads are refused when the disk has less free. The decoder may also spill 1.2 GiB while it works.
drop_per_hour 100 Photos one drop link can send per hour.
drop_per_day 300 Photos one drop link can send per day.
drop_ip_per_day 500 Photos one visitor address can send per day, across links.
php_binary Found automatically The only PHP used for the worker. Must be named php, php8 or php8.5. A configured path is the only one used, with no fallback.
heif_dec Found automatically The only HEIC converter. Must be named heif-dec or heif-convert.
sandbox 'bwrap' 'bwrap', 'user', 'auto' or 'none'. See Sandbox modes.

An unknown key stops the server with widgets.photos: unknown option. A value that is not a positive whole number stops it with widgets.photos.<key> must be a positive integer.

php
'widgets' => [
    'photos' => [
        'library_bytes' => 5 * 1024 ** 3,
        'album_photos'  => 5000,
        'upload_bytes'  => 30 * 1024 * 1024,
        'pending_album' => 500,
        'pending_bytes' => 512 * 1024 * 1024,
        'min_free_bytes' => 1024 ** 3,
        'drop_per_hour' => 100,
        'drop_per_day'  => 300,
        'drop_ip_per_day' => 500,
        // 'php_binary' => '/usr/bin/php8.5',
        // 'sandbox' => 'bwrap',
        // 'heif_dec' => '/usr/bin/heif-dec',
    ],
],

If you raise upload_bytes, raise LimitRequestBody in both .htaccess exceptions and post_max_size to match. See Install the frame server.

Where photos are stored

var/photos/
  files/ab/abcdef….jpg         frame-sized photo, named by its content hash
  files/ab/abcdef….thumb.jpg   thumbnail
  tmp/                          work area for uploads; leftovers older than an hour are removed during uploads
  • Photos are named by the hash of their bytes. The same picture in two albums is stored once.
  • var/photos/ must be on the same server and not under the web root. The app refuses to start the library if var_dir is inside the document root.
  • Photos are served only through the app, after a check that the requester is the right frame or a signed-in admin.
  • Back it up together with var/frames.db. Without the files the albums show blank photos. See Back up the server.

Make an album and add photos

  1. In the admin, select Photos in the top bar.
  2. Select New album. Enter a Name, up to 60 characters, such as "Family". Select Create album.

The album opens. It has these sections: Waiting for approval (only when something waits), Add photos, Photos, and Drop links. At the top are All albums, Rename and Delete album.

Add photos in bulk

Add photos takes files, folders, and ZIP files. Photos you add yourself are approved at once.

  1. Drag photos, folders or ZIP files onto the box, or select Choose photos or ZIPs or Choose a folder.
  2. Look over the list. Remove anything with the x button.
  3. Select Upload (it shows the count, for example Upload 40 photos).

While it runs, each row shows progress. Cancel stops after the current photo. Retry failed retries photos that failed in a way that may pass on a second try. Clear list empties the list. The summary line reads "12 added, 3 duplicates, 0 failed".

Details:

  • Accepted: JPEG, PNG, WebP and HEIC, up to 30 MiB each (the upload_bytes limit).
  • Photos upload one at a time, in order.
  • The list holds up to 10,000 photos. Hidden files and folders (names starting with a dot, and __MACOSX) are skipped, and so is anything that is not a photo.
  • Folders are read in full, including sub-folders, down to 25 levels.
  • ZIP files are opened in your browser. The browser unpacks one photo at a time as its turn to upload comes, so memory use stays at one photo.

ZIP limits, set in the browser:

Limit Value
Files in one ZIP 5000
Photos inside one ZIP, all together 2 GB
Size one file may unpack to, compared with its packed size At most 200 times larger
ZIP64 archives Not supported. Unzip on your computer and add the folder.
Archives split across several parts Not supported
Password-protected files Skipped
Compressed ZIPs Need a browser with DecompressionStream: Chrome, Edge, Firefox, or Safari 16.4 or newer

If a photo upload stops with a message, the rows that were not tried stay in the list.

Duplicates

A photo already in the same album is recognized and skipped: the row says "Already in this album". The server compares the file's bytes and also the processed picture, so a re-saved copy of the same photo is caught too. The same photo can exist in two different albums.

What the rows can say

Row says What it means and what to do
Too large (max 30 MB) The file is bigger than upload_bytes. Shrink it, or raise the limit.
Not a JPEG, PNG, WebP or HEIC photo The file type is not accepted.
CMYK images aren't supported - save as RGB JPEG Export the photo as sRGB.
Animated images aren't supported Use a still image.
iPhone HEIC photos aren't supported on this server yet heif-dec is missing or not working. See Troubleshooting.
Photo processing is unavailable on the server The decoder or sandbox check failed. Uploads stop. See Troubleshooting.
This album is full The album reached album_photos. Uploads stop.
The server is out of space The library reached library_bytes, or the disk has less than min_free_bytes free. Uploads stop.
The server is busy. Try again in a moment. Another upload holds the lock. The admin retries three times on its own.
Can't reach the server Check your connection. Uploads stop.

Review and delete

The Photos grid shows thumbnails, 48 at a time. Show more loads the next page. Select a photo to see it larger, with its size, when it was added and who added it.

To delete photos:

  1. Tick the photos, or Select all shown.
  2. Select Delete.
  3. Confirm. It asks for your password. The admin sends large selections in batches of 500.

Deleting cannot be undone. Frames that are showing a deleted photo drop it within a few minutes.

To delete an album, select Delete album and confirm. This removes the album, all its photos, and its drop links. The admin refuses while a frame still uses the album: point that frame at something else first.

Point a frame at the album

  1. Open the frame, then Widgets, switch Photos on, and select Save.
  2. Open the Photos section.
  3. Under Photos come from, choose Photo library.
  4. Choose the Album. The list shows the photo count.
  5. Check New photos daily at, and Show each photo for.
  6. Select Save.

A drop link lets a relative add photos to one album from their phone, with no account. Their photos wait for your approval. They cannot see anything in the album.

You need method 2 working first: the library, an album, and a sandbox you trust.

  1. In the admin, select Photos, then open the album.
  2. In Drop links, select New drop link.
  3. Enter a Name, up to 40 characters, for the person it is for, such as "Aunt Sue". The admin shows it in your list, and the page they open greets them with it ("Hi, Aunt Sue."), so use a name they would recognize.
  4. Select Create link.
  5. In the box that opens, select Copy link and send it to that person.

The link looks like https://frames.example.com/frames/drop/<43 characters>.

Warning

The link is shown once. The server keeps only a hash. If you lose it, make a new link.

Treat links as secrets. They also appear in your web server's access log. Rotate or delete logs on a short schedule.

What relatives see

The link opens a page titled "Send photos to the frame" with their name ("Hi, Aunt Sue."). It says: "Your photos will appear on the frame after they're approved." They tap Choose photos, pick photos from their phone, and the page sends them one by one. It shows how many more photos they can send right now. It gives them nothing else: no album, no other photos, no gallery.

If the link has expired, been revoked, or been replaced, the page says "This link isn't working anymore. Ask for a new one." Your name is owner_name from config.php.

Approve what arrives

Photos from a link wait in Waiting for approval, at the top of the album. Nothing waiting ever reaches a frame. In the album list, a Waiting column shows "N to approve".

  1. Open the album.
  2. In Waiting for approval, tick the photos. Each shows who sent it.
  3. Select Approve to add them to the album, or Reject to delete them.

The Drop links table shows each link's name, status (Active, Revoked or Expired), expiry, when it was last used, and how many photos it sent (with how many are waiting).

Action What it does
Rename Changes the name.
Extend Adds 90 days to the expiry, up to one year from today.
New link Replaces the link. The old address stops working at once. Shows the new one once. Asks for your password.
Revoke Turns the link off at once. Photos already sent stay in the album. Asks for your password.
Restore Turns a revoked link back on. The same address works again. Asks for your password.
Delete their photos Deletes every photo that link sent, waiting and approved. Cannot be undone. Asks for your password.
Delete link Removes the link. Photos it sent stay in the album. Asks for your password.

Links expire after 90 days. A link that expires shows Expired and stops working. Use Extend to renew it.

Quotas

Limit Default If a relative hits it
Photos per link per hour 100 (drop_per_hour) The page waits and retries, then says "You've sent a lot of photos. Please wait a while, then tap Try again."
Photos per link per day 300 (drop_per_day) The same.
Photos per visitor address per day 500 (drop_ip_per_day) The same.
Photos waiting in one album 500 (pending_album) "The frame can't take photos right now. Please try again later."
Waiting photos, all albums 512 MiB (pending_bytes) The same.
Size of one photo 30 MiB (upload_bytes) "Too big (the limit is 30 MB)."

Clear a full pending queue by approving or rejecting photos.

Logs

The application logs one frame photos: ingest ... line per upload: album, size, type and result. It never writes a link. The only IP address it writes is the rate-limit key.

Troubleshooting

bin/migrate.php checks the photo library and prints a WARNING (photos) line for each problem and a NOTE (photos) line for information. They never stop the migration. Run it as the web user:

sudo -u www-data php /srv/frame/app/bin/migrate.php

The migration runs the same self-test the admin uses. It decodes small test photos through the real worker, including a HEIC through heif-dec. While the self-test fails, the admin shows a banner in the album, "Photo uploads are off. The server's image decoder isn't working, so new photos can't be added.", and turns uploads off. Frames keep showing photos already stored.

Line What it means What to do
photo uploads are off: prlimit and nice (util-linux and coreutils) must be installed One of the two programs is missing. Install util-linux and coreutils.
photo uploads are off: widgets.photos.php_binary is '...', which is not an executable file named php, php8 or php8.5 (there is no fallback) The configured PHP path is wrong. Fix php_binary, or remove it to let the server find PHP.
photo uploads are off: no PHP command line binary found for the image worker (set 'php_binary' ...) The server could not find a command-line PHP. Install the CLI package, or set php_binary.
photo uploads are off: the image worker cannot run PHP GD with JPEG, PNG and WebP support ... GD is missing from the command-line PHP, or lacks a format. Install php8.5-gd for the CLI. Check with the gd_info command in What the server needs.
photo uploads are off: widgets.photos.sandbox is 'user' but the framephoto setup does not work ... Something in the dedicated user setup is missing or broken. Run the checks in that section. Re-run the setup script. Read frame photos: framephoto setup does not work in the PHP error log.
photo uploads are off: the bubblewrap sandbox is required ... but cannot run here bwrap is missing or blocked. Install bubblewrap. On Ubuntu 24.04, use 'sandbox' => 'user'. See When Ubuntu 24.04 blocks it.
NOTE (photos): widgets.photos.sandbox is 'none' ... You chose no sandbox. Nothing, if you meant it.
NOTE (photos): the image worker runs without the bubblewrap sandbox (bwrap is missing or cannot create namespaces here) ... 'sandbox' => 'auto' fell back. Install bubblewrap, switch to user, or set 'none' to silence it.
HEIC (iPhone) photos are refused: heif-dec is not installed (apt install libheif-examples libheif-plugin-libde265) No HEIC converter. Everything else still works. Install the two packages.
HEIC (iPhone) photos are refused: heif-dec ran but could not decode the self-test photo ... heif-dec exists but failed. Install libheif-plugin-libde265. Check that heif-dec works inside the sandbox.
libheif X is below the security floor ... The libheif library is older than 1.17.6. Update it. A distribution may fix it under the old number, so check your security updates.
libwebp library X is below the security floor ... libwebp is older than 1.3.2 (shared object 7.1.8). Update it.
WARNING (photos): the host check failed: ... The check itself crashed. Read the message and the PHP error log.

The checks are cached. A failed sandbox check is remembered for 10 minutes and a passed one for an hour. The admin's upload check is remembered for an hour. After a fix, delete var/photos/.sandbox-state, var/photos/.sandbox-state-user and var/photos/.probe-ok to test again now.

Other lines in the PHP error log that help:

  • frame photos: worker failed exit=... stderr=.... The worker crashed, was killed, or printed something unexpected.
  • frame photos: image self-test failed code=....
  • frame photos: heif-dec failed ....
  • frame photos: ingest ... result=.... One line per upload.

Next: Update the kiosk.