# Photo carousel with Ken Burns effects

**URL:** <https://community.info-beamer.com/t/photo-carousel-with-ken-burns-effects/1411>\
**Category:** Your info-beamer projects\
**Created:** [August 29, 2026, 3:35pm UTC](https://community.info-beamer.com/t/photo-carousel-with-ken-burns-effects/1411 "2026-08-29T15:35:14Z")\
**Posts on this page:** 1\
**Page:** 1

<div class="post-metadata">

**Author:** ![critias](https://community.info-beamer.com/letter_avatar_proxy/v3/letter/c/d2c977/32.png) [@critias](https://community.info-beamer.com/u/critias)\
**Post date:** [August 29, 2026, 3:35pm UTC](https://community.info-beamer.com/t/photo-carousel-with-ken-burns-effects/1411/1 "2026-08-29T15:35:14Z")

</div>

Hey info-beam lovers!

I’m a university prof and I’ve been trying for years to have a nice carousel of our laboratory pictures, with more than just fade in/out, and could never get a decent Ken Burns effect on a Raspberry Pi. I ran into _info-beamer pi_ recently and was able to set up a very nice picture frame, with the help of ChatGPT.

Basically my prompt was: “_I want to use my Raspberry Pi 5 as a digital photo frame with Ken Burns effect. I have installed info-beamer pi. Help me set up the following slideshow: Pictures in landscape mode should be displayed with the Ken Burns effect from one corner to the other. Picture that are “panoramic” should be slowly panned from left-to right, so that the whole picture is eventually shown. Pictures that are in portrait should be presented with left and right black borders._”

The result is great, and I’m attaching both the LUA script and the view of the frame (and our labs!).

 ![Lab_photo_frame](https://community.info-beamer.com/uploads/default/original/1X/d3292ed72051194b770e2e73f808376179856c63.jpeg)

```auto
gl.setup(NATIVE_WIDTH, NATIVE_HEIGHT)

----------------------------------------------------------------------
-- Configuration
----------------------------------------------------------------------

-- Total time for each photograph, in seconds
local SLIDE_TIME = 60.0

-- Fade to/from black
local FADE_TIME = 4.0

-- A landscape photograph becomes a panorama when:
--
-- image_aspect > screen_aspect * PANORAMA_FACTOR
--
-- On a 16:9 display:
-- 1.777 * 1.25 = 2.22
--
local PANORAMA_FACTOR = 1.25

-- Ken Burns zoom range
local KB_ZOOM_MIN = 1.02
local KB_ZOOM_MAX = 1.14

-- Default corporate logo brightness.
-- 0.05 means 5% opacity over black.
local DEFAULT_LOGO_ALPHA = 0.15

-- Safety limit: even if a larger value is requested externally,
-- do not display the logo above this brightness.
local MAX_LOGO_ALPHA = 0.90

----------------------------------------------------------------------
-- Utility functions
----------------------------------------------------------------------

local function clamp(v, lo, hi)
    if v < lo then return lo end
    if v > hi then return hi end
    return v
end

local function lerp(a, b, t)
    return a + (b - a) * t
end

-- Smooth acceleration/deceleration
local function smoothstep(t)
    t = clamp(t, 0, 1)
    return t * t * (3 - 2 * t)
end

----------------------------------------------------------------------
-- Load playlist
----------------------------------------------------------------------

local function load_playlist()
    local list = {}
    local data = resource.load_file("photos.txt")

    for line in data:gmatch("[^\r\n]+") do
        -- Ignore blank lines and comments
        if line:match("%S") and line:sub(1,1) ~= "#" then
            list[#list + 1] = line
        end
    end

    return list
end

local photos = load_playlist()

if #photos == 0 then
    error("photos.txt contains no photographs")
end

print(string.format(
    "Loaded playlist containing %d photographs",
    #photos
))

----------------------------------------------------------------------
-- Slideshow state
----------------------------------------------------------------------

local current = nil
local pending = nil
local slide_started = sys.now()

----------------------------------------------------------------------
-- Corporate logo
----------------------------------------------------------------------

-- logo.png must be located in the node directory.
local logo = resource.load_image("logoETS.png")

local logo_override = false
local logo_alpha = DEFAULT_LOGO_ALPHA

-- Give this node a predictable address on info-beamer's
-- UDP control interface.
node.alias("photo-frame")

----------------------------------------------------------------------
-- Corporate logo control
--
-- Accepted messages:
--
-- photo-frame:logo:on
-- photo-frame:logo:on:0.03
-- photo-frame:logo:off
--
----------------------------------------------------------------------

node.event("data", function(data, suffix)

    --------------------------------------------------------------
    -- Enable logo
    --------------------------------------------------------------

    if data == "logo:on" then

        logo_alpha = DEFAULT_LOGO_ALPHA
        logo_override = true

        print(string.format(
            "Corporate logo enabled at brightness %.3f",
            logo_alpha
        ))

        return
    end

    --------------------------------------------------------------
    -- Enable logo with explicit brightness
    --------------------------------------------------------------

    local requested_alpha =
        data:match("^logo:on:([0-9]*%.?[0-9]+)$")

    if requested_alpha then

        local a = tonumber(requested_alpha)

        if a then
            logo_alpha = clamp(
                a,
                0.01,
                MAX_LOGO_ALPHA
            )

            logo_override = true

            print(string.format(
                "Corporate logo enabled at brightness %.3f",
                logo_alpha
            ))
        end

        return
    end

    --------------------------------------------------------------
    -- Disable logo
    --------------------------------------------------------------

    if data == "logo:off" then

        if logo_override then
            logo_override = false

            -- Start the current photograph again from the beginning
            -- of its Ken Burns/pan sequence.
            slide_started = sys.now()

            print("Corporate logo disabled")
        end

        return
    end

end)

----------------------------------------------------------------------
-- Image loading
----------------------------------------------------------------------

local function next_index(idx)
    return (idx % #photos) + 1
end

local function load_photo(idx)

    local path = photos[idx]

    print(string.format(
        "Loading %d/%d: %s",
        idx,
        #photos,
        path
    ))

    local img = resource.load_image {
        file = path;
        mipmap = true;
    }

    return {
        image = img;
        path = path;
        idx = idx;
        ready = false;
        failed = false;
    }
end

local function poll_load(job)

    if not job or job.ready or job.failed then
        return
    end

    local state, a, b = job.image:state()

    if state == "loaded" then

        job.ready = true
        job.w = a
        job.h = b

        print(string.format(
            "Loaded %s (%dx%d, aspect %.3f)",
            job.path,
            job.w,
            job.h,
            job.w / job.h
        ))

    elseif state == "error" then

        print(string.format(
            "ERROR loading %s: %s",
            job.path,
            tostring(a)
        ))

        job.image:dispose()
        job.failed = true
    end
end

local function queue_after(idx)
    pending = load_photo(next_index(idx))
end

local function promote(job, now)

    if current then
        current.image:dispose()
    end

    current = job
    pending = nil
    slide_started = now

    --------------------------------------------------------------
    -- Select one of four diagonal Ken Burns paths.
    --------------------------------------------------------------

    current.corner_path = (current.idx - 1) % 4

    --------------------------------------------------------------
    -- Alternate zoom-in and zoom-out.
    --------------------------------------------------------------

    current.zoom_in = (current.idx % 2 == 1)

    --------------------------------------------------------------
    -- Preload next photograph.
    --------------------------------------------------------------

    queue_after(current.idx)
end

----------------------------------------------------------------------
-- Portrait rendering
----------------------------------------------------------------------

local function draw_portrait(job, alpha)

    -- gl.clear() has already made the entire screen black.
    --
    -- util.draw_correct fits the complete image while preserving
    -- aspect ratio. A portrait photograph therefore naturally
    -- receives black borders on its left and right sides.

    util.draw_correct(
        job.image,
        0, 0,
        WIDTH, HEIGHT,
        alpha
    )
end

----------------------------------------------------------------------
-- Panorama rendering
----------------------------------------------------------------------

local function draw_panorama(job, t, alpha)

    local image_aspect = job.w / job.h
    local screen_aspect = WIDTH / HEIGHT

    --------------------------------------------------------------
    -- Display the photograph at full screen height.
    --
    -- Determine what fraction of the source width corresponds
    -- to one complete screen.
    --------------------------------------------------------------

    local crop_w = screen_aspect / image_aspect
    crop_w = clamp(crop_w, 0, 1)

    --------------------------------------------------------------
    -- Smooth left -> right movement.
    --------------------------------------------------------------

    local p = smoothstep(t)

    local x1 = (1.0 - crop_w) * p
    local x2 = x1 + crop_w

    --------------------------------------------------------------
    -- At t=0:
    --
    -- [0 ........ crop_w]
    --
    -- At t=1:
    --
    -- [1-crop_w ........ 1]
    --
    -- Every horizontal part of the panoramic photograph is
    -- therefore eventually shown.
    --------------------------------------------------------------

    job.image:draw(
        0, 0,
        WIDTH, HEIGHT,
        alpha,
        x1, 0,
        x2, 1
    )
end

----------------------------------------------------------------------
-- Landscape Ken Burns rendering
----------------------------------------------------------------------

local function draw_landscape(job, t, alpha)

    local image_aspect = job.w / job.h
    local screen_aspect = WIDTH / HEIGHT

    --------------------------------------------------------------
    -- Determine the largest source rectangle that exactly matches
    -- the display aspect ratio.
    --------------------------------------------------------------

    local base_w
    local base_h

    if image_aspect >= screen_aspect then

        -- Source is wider than the display.
        base_h = 1.0
        base_w = screen_aspect / image_aspect

    else

        -- Source is taller than the display.
        base_w = 1.0
        base_h = image_aspect / screen_aspect
    end

    --------------------------------------------------------------
    -- Position within the animation.
    --------------------------------------------------------------

    local p = smoothstep(t)

    --------------------------------------------------------------
    -- Zoom
    --------------------------------------------------------------

    local zoom

    if job.zoom_in then

        zoom = lerp(
            KB_ZOOM_MIN,
            KB_ZOOM_MAX,
            p
        )

    else

        zoom = lerp(
            KB_ZOOM_MAX,
            KB_ZOOM_MIN,
            p
        )
    end

    local crop_w = base_w / zoom
    local crop_h = base_h / zoom

    --------------------------------------------------------------
    -- Corner-to-corner motion
    --
    -- 0 = upper-left -> lower-right
    -- 1 = upper-right -> lower-left
    -- 2 = lower-left -> upper-right
    -- 3 = lower-right -> upper-left
    --------------------------------------------------------------

    local sx, sy
    local ex, ey

    if job.corner_path == 0 then

        sx, sy = 0, 0
        ex, ey = 1, 1

    elseif job.corner_path == 1 then

        sx, sy = 1, 0
        ex, ey = 0, 1

    elseif job.corner_path == 2 then

        sx, sy = 0, 1
        ex, ey = 1, 0

    else

        sx, sy = 1, 1
        ex, ey = 0, 0
    end

    --------------------------------------------------------------
    -- Interpolate the source crop.
    --------------------------------------------------------------

    local x1 = lerp(
        sx * (1.0 - crop_w),
        ex * (1.0 - crop_w),
        p
    )

    local y1 = lerp(
        sy * (1.0 - crop_h),
        ey * (1.0 - crop_h),
        p
    )

    local x2 = x1 + crop_w
    local y2 = y1 + crop_h

    --------------------------------------------------------------
    -- Draw the selected source rectangle over the entire display.
    --------------------------------------------------------------

    job.image:draw(
        0, 0,
        WIDTH, HEIGHT,
        alpha,
        x1, y1,
        x2, y2
    )
end

----------------------------------------------------------------------
-- Corporate logo rendering
----------------------------------------------------------------------

local function draw_logo()

    local state = logo:state()

    if state ~= "loaded" then
        return
    end

    --------------------------------------------------------------
    -- The entire screen has already been cleared to black.
    --
    -- Leave a substantial margin around the logo and preserve
    -- its aspect ratio.
    --------------------------------------------------------------

    util.draw_correct(
        logo,

        WIDTH * 0.15,
        HEIGHT * 0.15,

        WIDTH * 0.85,
        HEIGHT * 0.85,

        logo_alpha
    )
end

----------------------------------------------------------------------
-- Render current photograph
----------------------------------------------------------------------

local function draw_current(now)

    if not current then
        return
    end

    local elapsed = now - slide_started

    local t = clamp(
        elapsed / SLIDE_TIME,
        0,
        1
    )

    --------------------------------------------------------------
    -- Fade
    --------------------------------------------------------------

    local alpha = 1.0

    -- Fade in
    if elapsed < FADE_TIME then
        alpha = elapsed / FADE_TIME
    end

    -- Fade out only if the next photograph is already available.
    if pending and pending.ready then

        if elapsed > SLIDE_TIME - FADE_TIME then

            alpha = math.min(
                alpha,
                (SLIDE_TIME - elapsed) / FADE_TIME
            )
        end
    end

    alpha = clamp(alpha, 0, 1)

    --------------------------------------------------------------
    -- Classify photograph
    --------------------------------------------------------------

    local image_aspect = current.w / current.h
    local screen_aspect = WIDTH / HEIGHT

    if current.w < current.h then

        ----------------------------------------------------------
        -- Portrait
        ----------------------------------------------------------

        draw_portrait(
            current,
            alpha
        )

    elseif image_aspect >
           screen_aspect * PANORAMA_FACTOR then

        ----------------------------------------------------------
        -- Panorama
        ----------------------------------------------------------

        draw_panorama(
            current,
            t,
            alpha
        )

    else

        ----------------------------------------------------------
        -- Ordinary landscape
        ----------------------------------------------------------

        draw_landscape(
            current,
            t,
            alpha
        )

    end
end

----------------------------------------------------------------------
-- Start loading first photograph
----------------------------------------------------------------------

pending = load_photo(1)

----------------------------------------------------------------------
-- Main render loop
----------------------------------------------------------------------

function node.render()

    local now = sys.now()

    --------------------------------------------------------------
    -- Always start with a pure black display.
    --------------------------------------------------------------

    gl.clear(0, 0, 0, 1)

    --------------------------------------------------------------
    -- Poll asynchronous photo loader.
    --------------------------------------------------------------

    poll_load(pending)

    --------------------------------------------------------------
    -- Skip unreadable photographs.
    --------------------------------------------------------------

    if pending and pending.failed then

        local bad_idx = pending.idx

        pending = load_photo(
            next_index(bad_idx)
        )

        poll_load(pending)
    end

    --------------------------------------------------------------
    -- Establish first photograph.
    --------------------------------------------------------------

    if not current then

        if pending and pending.ready then
            promote(pending, now)
        end
    end

    --------------------------------------------------------------
    -- CORPORATE LOGO OVERRIDE
    --
    -- Important: this occurs BEFORE advancing the slideshow.
    -- Consequently the current photograph remains selected while
    -- the logo is visible.
    --------------------------------------------------------------

    if logo_override then

        draw_logo()
        return
    end

    --------------------------------------------------------------
    -- Advance to the following photograph once its display time
    -- has expired, but only if that photograph is ready.
    --------------------------------------------------------------

    if current and
       pending and
       pending.ready and
       now - slide_started >= SLIDE_TIME then

        promote(
            pending,
            now
        )
    end

    --------------------------------------------------------------
    -- Normal slideshow
    --------------------------------------------------------------

    draw_current(now)

end

```
