#!/usr/bin/env bash
# The stage set for the showcase video: a directory that looks like somebody's working files, built
# from nothing, reproducible, and reachable by every surface and state Flea can draw.
#   tools/flea-showcase-content build      make the stage set, print the tree and what it weighs
#   tools/flea-showcase-content list       print the tree of a stage set already built
#   tools/flea-showcase-content montage    montage the photographs at the sizes Flea actually draws
#   tools/flea-showcase-content remove     delete it
# Nothing here is downloaded and nothing is copied from a real directory: every photograph, page,
# frame and sample is generated by this file, so the whole set is safe to put on a public timeline.
# Hard rule 9: every write and delete is inside a sandbox tools/flea-sandbox-guard made and marked.
set -u
set -o pipefail
. "$(dirname "$0")/../tools/flea-sandbox-guard"

# The sandbox carries the guard's marker; the stage is one level inside it, so the marker never
# appears in a listing on camera even with hidden files shown.
sandbox=$FIXTURE_ROOT/flea-showcase-content
stage=$sandbox/studio
denied="$stage/Client vault"

# The camera roll is what makes the listing crawl and scroll. Reflink clones, not hard links: btrfs
# shares the extents so they cost almost nothing, and unlike a hard link each clone carries its own
# mtime, which is what keeps the date column from repeating.
roll_count=${FLEA_ROLL_COUNT:-8000}
# One magick per photograph, each single-threaded, as many at once as the box has cores.
jobs=${FLEA_SHOWCASE_JOBS:-10}
# The scene is composed at this width and enlarged once at the end. Every costly operator here is a
# blur or a plasma and both cost per pixel, so composing small is what takes a photograph from 11 s
# to under 2 s. Flea never draws a full-resolution image anyway: the largest it ever shows is the
# 256 px freedesktop thumbnail in the preview column.
compose_width=800
# The largest single file on the set, so the size column has a real number on it. It is NOT the way
# to demonstrate transfer progress: measured through the backend, copying 120 MB of it took 81 ms and
# emitted one progress line, because Flea does not fsync and the write lands in page cache at roughly
# 1.5 GB/s. Byte progress on one file would need a multi-GB file. What does run visibly is a
# multi-select: 300 files copied in 505 ms with 300 item lines, so about 2000 rows of the camera roll
# is the three-second shot.
big_file_mb=${FLEA_BIG_FILE_MB:-48}

fail() {
    printf 'flea-showcase-content: %s\n' "$*" >&2
    exit 1
}

# ------------------------------------------------------------------ the photograph generator

# Twelve skies. Each names its own silhouette profile, because a city skyline under a noon sky reads
# as a diagram and a treeline under a sunset reads as a photograph. Columns: name, sky top, sky
# bottom, glow, silhouette ink, profile.
palettes=(
    "alpine   #2f5f96 #cfe3ee #ffffff #1b2733 ridge"
    "sunset   #17335e #ffb877 #ffe4a8 #120f14 ridge"
    "dunes    #8a5f2e #f4d29a #fff0c0 #3a220f dune"
    "pine     #93a893 #e6efe6 #ffffff #16241a treeline"
    "dusk     #101a3a #e8845a #ffd9a0 #0b0d16 city"
    "dawn     #3a3f6b #f6c9b0 #ffe9d0 #181a24 ridge"
    "mist     #5c7a86 #dfe9ec #ffffff #1e2a2e treeline"
    "storm    #2b3138 #8e9aa4 #c9d3da #10151a ridge"
    "harbour  #14304a #d9b48a #ffe3b8 #0d141c city"
    "meadow   #6f8f5a #dfe7c8 #fffbe0 #24301c treeline"
    "glacier  #3b6f8f #e2f0f5 #ffffff #17262e ridge"
    "ember    #40202c #f0a070 #ffd0a0 #180d12 dune"
)
# Four shapes of frame, so an album is not twelve identical rectangles.
aspects=(3x2 4x3 16x9 2x3)

# Blend two #rrggbb colours; pct is how much of the first survives. A distant ridge takes more of
# the sky than a near one, which is the whole of aerial perspective and the reason a ridge reads
# as far away rather than as a shape sitting on top of the picture.
mix_hex() {
    local a=${1#\#} b=${2#\#} pct=$3
    printf '#%02x%02x%02x' \
        $(( (16#${a:0:2} * pct + 16#${b:0:2} * (100 - pct)) / 100 )) \
        $(( (16#${a:2:2} * pct + 16#${b:2:2} * (100 - pct)) / 100 )) \
        $(( (16#${a:4:2} * pct + 16#${b:4:2} * (100 - pct)) / 100 ))
}

# One silhouette, as a polygon closed down to the bottom edge. The walk is mean-reverting: a plain
# random walk drifts, and a drifted ridge is one cartoon mega-peak instead of a skyline.
ridge_points() {
    local profile=$1 base=$2 amp=$3 step=$4 w=$5 h=$6
    local x=0 advance deviate roof
    local y=$base
    local points="0,$h 0,$y"
    while [ "$x" -lt "$w" ]; do
        advance=$(( step + RANDOM % (step / 2 + 1) ))
        x=$(( x + advance ))
        [ "$x" -gt "$w" ] && x=$w
        if [ "$profile" = city ]; then
            roof=$(( base - RANDOM % amp ))
            [ "$roof" -lt $((h / 16)) ] && roof=$((h / 16))
            points="$points $x,$y $x,$roof"
            y=$roof
        else
            deviate=$(( RANDOM % (2 * amp + 1) - amp ))
            # The pull back toward base is what stops the walk drifting off the frame.
            y=$(( y + deviate - (y - base) / 3 ))
            [ "$y" -lt $((h / 12)) ] && y=$((h / 12))
            [ "$y" -gt $((h - h / 9)) ] && y=$((h - h / 9))
            points="$points $x,$y"
        fi
    done
    printf '%s %s,%s %s,%s' "$points" "$w" "$y" "$w" "$h"
}

# One photograph. Sky gradient, a blurred glow for the sun, plasma cloud texture, then silhouette
# layers from far to near, optional water, and finally grain and a vignette at full size. The grain
# is not decoration: a smooth synthetic gradient compresses to almost nothing, and a photograph that
# weighs 30 KB gives the size column away.
render_photo() {
    local out=$1 palette_index=$2 variant=$3
    local name top bottom glow ink profile
    read -r name top bottom glow ink profile <<<"${palettes[palette_index]}"

    # Seeded from the two indices, so a rebuild between takes produces the same stage set.
    RANDOM=$(( palette_index * 977 + variant * 31 + 7 ))

    local aspect=${aspects[$(( variant % ${#aspects[@]} ))]}
    local full_w full_h
    case $aspect in
        3x2)  full_w=2400; full_h=1600 ;;
        4x3)  full_w=2048; full_h=1536 ;;
        16x9) full_w=2560; full_h=1440 ;;
        2x3)  full_w=1600; full_h=2400 ;;
    esac
    local w=$compose_width
    local h=$(( compose_width * full_h / full_w ))
    local work=$out.build

    # Where the sun sits, and how many ridges: the two things that make one palette look like five
    # different afternoons. The glow is composed at a quarter scale and enlarged, so its blur is four
    # times wider for a sixteenth of the cost, and it reads as haze rather than as a pasted-on disc.
    # It is dimmed before it is screened on: a full-strength glow that wide takes a pale sky to white.
    local glow_x=$(( w * (18 + (variant * 17 + palette_index * 7) % 65) / 100 ))
    local glow_y=$(( h * (30 + (variant * 23) % 30) / 100 ))
    local layers=$(( 3 + (variant + palette_index) % 3 ))
    local water=no
    [ $(( (variant + palette_index) % 3 )) -eq 0 ] && water=yes

    magick -size ${w}x${h} gradient:"${top}-${bottom}" \
        \( -size $(( w / 4 ))x$(( h / 4 )) xc:black -fill "$glow" \
           -draw "circle $(( glow_x / 4 )),$(( glow_y / 4 )) $(( glow_x / 4 )),$(( glow_y / 4 - w / 26 ))" \
           -blur 0x$(( w / 20 )) -evaluate multiply 0.55 -resize ${w}x${h}! \) \
        -compose screen -composite \
        \( -size ${w}x${h} plasma:fractal -colorspace gray -blur 0x$(( w / 60 )) -contrast-stretch 6%x6% \) \
        -compose softlight -composite "$work.png" || fail "magick failed on $out"

    local i base amp step weight blur points colour denominator
    denominator=$(( layers > 1 ? layers - 1 : 1 ))
    for (( i = 0; i < layers; i++ )); do
        base=$(( h * (54 + i * 9) / 100 ))
        amp=$(( h * (3 + i * 2) / 100 ))
        step=$(( w * (13 - i * 3) / 100 ))
        case $profile in
            treeline) amp=$(( h * (2 + i) / 100 )); step=$(( w * (3 - i) / 100 )) ;;
            dune)     amp=$(( h * (3 + i * 2) / 100 )); step=$(( w * (18 - i * 4) / 100 )) ;;
            city)     amp=$(( h * (20 + i * 12) / 100 )); step=$(( w * (9 - i * 2) / 100 )) ;;
        esac
        [ "$step" -lt 3 ] && step=3
        [ "$amp" -lt 2 ] && amp=2
        weight=$(( 34 + i * 66 / denominator ))
        colour=$(mix_hex "$ink" "$bottom" "$weight")
        blur=$(( layers - 1 - i ))
        points=$(ridge_points "$profile" "$base" "$amp" "$step" "$w" "$h")
        magick "$work.png" \
            \( -size ${w}x${h} xc:none -fill "$colour" -draw "polygon $points" -blur 0x${blur}.4 \) \
            -compose over -composite "$work.next.png" || fail "magick failed on a ridge of $out"
        mv "$work.next.png" "$work.png"
    done

    if [ "$water" = yes ]; then
        # What the water reflects is the band immediately above the shoreline, not the top of the
        # frame: reflecting the zenith puts the sun's brightest, most saturated pixels into the lake
        # and reads as a neon puddle rather than as water.
        # The smear is a squash of one axis and a stretch back. -motion-blur draws the same thing and
        # costs 7 s a frame here, which is the whole build budget for one reflection.
        local sea_h=$(( h * 30 / 100 ))
        magick "$work.png" -crop ${w}x${sea_h}+0+$(( h - 2 * sea_h )) +repage -flip -modulate 76,58 \
            -resize 10%x100%! -resize 1000%x100%! -resize ${w}x${sea_h}! -blur 0x1 "$work.sea.png" \
            || fail "magick failed on the water of $out"
        magick "$work.png" "$work.sea.png" -gravity south -compose over -composite "$work.next.png" \
            || fail "magick failed compositing the water of $out"
        mv "$work.next.png" "$work.png"
    fi

    magick "$work.png" -resize ${full_w}x${full_h}! \
        -attenuate 0.42 +noise Gaussian \
        \( -size ${full_w}x${full_h} radial-gradient:white-"#7a7a7a" \) -compose multiply -composite \
        -modulate 100,106 -quality 88 "$out" || fail "magick failed writing $out"
    rm -f "$work".*.png "$work.png"
}

# ------------------------------------------------------------------ the names on camera

# Written out rather than generated, because these are the strings a viewer reads in a screenshot
# and a numbered sequence is what makes a listing look like a fixture. Lengths vary on purpose, so
# the eliding behaviour is visible without contriving it.
iceland_names=(
    "black sand beach, low tide.jpg"
    "DSC_4821.jpg"
    "DSC_4822.jpg"
    "DSC_4839.jpg"
    "glacier lagoon at dusk.jpg"
    "harbour, before the weather came in.jpg"
    "DSC_4901.jpg"
    "basalt columns.jpg"
    "DSC_4907.jpg"
    "the long road east.jpg"
    "DSC_4912.jpg"
    "waterfall, second attempt with the tripod.jpg"
    "DSC_4930.jpg"
    "moss field.jpg"
    "DSC_4944.jpg"
    "last light over the fjord.jpg"
    "DSC_4951.jpg"
    "ferry terminal, waiting.jpg"
)
studio_names=(
    "backlight test, 85mm.jpg"
    "chair and window.jpg"
    "colour chart.jpg"
    "grey card reference.jpg"
    "key light only.jpg"
    "product turntable, frame 01.jpg"
    "product turntable, frame 12.jpg"
    "softbox left.jpg"
    "test roll 01.jpg"
    "test roll 02.jpg"
    "white balance check.jpg"
    "wide setup.jpg"
)
shot_names=(
    "hero shot, dark.jpg"
    "hero shot, light.jpg"
    "in situ, desk.jpg"
    "in situ, shelf.jpg"
    "packaging front.jpg"
    "packaging back.jpg"
    "detail, hinge.jpg"
    "detail, seam.jpg"
)

# ------------------------------------------------------------------ documents

# A typeset multi-page contract. groff -ms is already on the box for man pages, so this needs
# nothing installed. Both parties are roles rather than names: this goes on a public timeline.
write_contract() {
    local out=$1 clause
    local source=$out.ms
    {
        printf '.TL\nAgreement for Design Services\n'
        printf '.LP\nThis agreement is made between the Studio, which provides design and production\n'
        printf 'services, and the Client, which commissions them. It takes effect on the date the\n'
        printf 'last party signs it and continues until the work described in Schedule A is\n'
        printf 'delivered and accepted, or until either party ends it under clause 9.\n'
        for clause in \
            "Scope of work:The Studio will carry out the work set out in Schedule A. Anything not\nlisted there is outside the scope of this agreement and is quoted separately.\n.PP\nWhere the Client asks for work beyond the schedule, the Studio will provide a\nwritten estimate before starting, and neither party is committed to that work\nuntil the estimate is accepted in writing." \
            "Fees and invoicing:Fees are as stated in Schedule B. The Studio invoices on delivery of each\nmilestone, and invoices fall due thirty days after the date of issue.\n.PP\nExpenses agreed in advance are recharged at cost, with receipts attached to the\ninvoice that carries them." \
            "Revisions:Each milestone includes two rounds of revision. Further rounds are charged at\nthe hourly rate in Schedule B.\n.PP\nA revision request is counted from the date the Client sends it in writing,\ncollected into a single list rather than as individual messages." \
            "Delivery:The Studio delivers source files and exported assets in the formats listed in\nSchedule A, by a transfer link that stays live for ninety days.\n.PP\nThe Client is responsible for keeping its own copy of delivered files after that\nperiod. The Studio keeps an archive copy for twelve months as a courtesy and not\nas an obligation." \
            "Ownership:On payment in full, the Client owns the delivered work. Until then the Studio\nretains ownership, and the Client may not publish or distribute it.\n.PP\nThe Studio keeps ownership of its own tools, templates and working files, and of\nany component it developed before this agreement began." \
            "Credit and portfolio:The Studio may show the delivered work in its portfolio and in submissions to\nawards, unless the Client asks otherwise in writing.\n.PP\nNeither party may imply an endorsement by the other beyond the fact that the work\nwas carried out." \
            "Confidentiality:Each party keeps the other's non-public information confidential, uses it only\nfor this engagement, and returns or destroys it on request.\n.PP\nThis obligation survives the end of the agreement by three years, and does not\ncover information that is already public through no fault of the receiving party." \
            "Liability:Neither party is liable to the other for indirect or consequential loss. The\nStudio's total liability is limited to the fees paid under this agreement.\n.PP\nNothing in this clause limits liability that cannot be limited by law." \
            "Ending the agreement:Either party may end this agreement by giving thirty days' written notice.\n.PP\nOn notice, the Client pays for work completed and for work in progress up to the\nnotice date, and the Studio delivers what has been paid for." \
            "Governing terms:This agreement is the whole of what has been agreed and replaces any earlier\nunderstanding between the parties.\n.PP\nA change to it is effective only when both parties record it in writing." \
        ; do
            printf '.NH\n%s\n.LP\n%b\n' "${clause%%:*}" "${clause#*:}"
        done
        printf '.NH\nSignatures\n.LP\nSigned for the Studio, and signed for the Client, each by an authorised\nrepresentative on the date written beside their signature.\n'
        printf '.sp 3\n.LP\n\\l\x27 2.5i\x27\\h\x271i\x27\\l\x27 2.5i\x27\n.LP\nfor the Studio\\h\x272.1i\x27for the Client\n'
        # The schedules start on their own page, which is also what takes the document past two
        # pages, so the page chevrons and the "n / m" readout have something to move through.
        printf '.bp\n.NH\nSchedule A: scope of work\n.LP\nThe Studio will deliver, in three milestones:\n'
        printf '.IP \\(bu 2\nA primary mark and a wordmark, each in a dark and a light variant, as\nvector source and as exported raster at four sizes.\n'
        printf '.IP \\(bu 2\nA colour palette of seven values, specified for screen and for coated and\nuncoated stock, with the conversions recorded rather than left to the printer.\n'
        printf '.IP \\(bu 2\nTwo days of photography, one in studio and one on location, delivered as\nforty retouched frames from a selection the Client approves.\n'
        printf '.IP \\(bu 2\nApplication templates for correspondence, presentation and social formats.\n'
        printf '.IP \\(bu 2\nA written guideline document covering spacing, minimum sizes, misuse, and\nthe photographic treatment.\n'
        printf '.LP\nNot included: motion work, copywriting, web implementation, and any print\nbuying. Each of these is available and is quoted separately.\n'
        printf '.NH\nSchedule B: fees\n.LP\nMilestone one, on acceptance of the marks: 4,800. Milestone two, on acceptance\nof the palette and templates: 3,200. Milestone three, on delivery of the\nphotography and the guideline document: 3,360.\n'
        printf '.PP\nWork beyond the schedule is charged at 95 an hour, estimated in writing before\nit starts. Expenses agreed in advance are recharged at cost.\n'
        printf '.PP\nAll figures exclude tax, which is added at the rate in force on the invoice\ndate.\n'
    } > "$source"
    groff -ms -T pdf "$source" > "$out" 2>/dev/null || fail "groff could not typeset $out"
    rm -f "$source"
}

# A one-page invoice, laid out with tbl so it reads as a real document rather than a wall of text.
write_invoice() {
    local out=$1
    local source=$out.ms
    {
        printf '.TL\nInvoice 0142\n'
        printf '.LP\nIssued 12 August 2026, due 11 September 2026.\n.sp\n'
        printf '.TS\nallbox tab(|);\nlb lb rb\nl l r.\n'
        printf 'Item|Detail|Amount\n'
        printf 'Identity, phase one|Marks, wordmark, colour|4,800.00\n'
        printf 'Identity, phase two|Application and templates|3,200.00\n'
        printf 'Photography|Two days, studio and location|2,400.00\n'
        printf 'Retouching|Forty frames|960.00\n'
        printf 'Print supervision|One press check|450.00\n'
        printf 'Expenses|Travel and materials, at cost|318.40\n'
        printf '|Total|12,128.40\n'
        printf '.TE\n.sp\n.LP\nPayable by transfer to the account on file. Please quote the invoice number.\n'
    } > "$source"
    groff -t -ms -T pdf "$source" > "$out" 2>/dev/null || fail "groff could not typeset $out"
    rm -f "$source"
}

# ------------------------------------------------------------------ media

# Audio carrying a cover image, because ffmpegthumbnailer's audio entry extracts embedded art and
# that is the only way an audio row draws a picture rather than a glyph.
write_audio() {
    local out=$1 seconds=$2 rate=$3 cover=$4 tone=$5
    ffmpeg -loglevel error -y \
        -f lavfi -i "sine=frequency=${tone}:sample_rate=${rate}:duration=${seconds}" \
        -f lavfi -i "anoisesrc=amplitude=0.02:sample_rate=${rate}:duration=${seconds}" \
        -i "$cover" \
        -filter_complex "[0:a][1:a]amix=inputs=2:duration=shortest,volume=0.25[a]" \
        -map "[a]" -map 2:v -c:v mjpeg -disposition:v attached_pic \
        "$out" || fail "ffmpeg could not write $out"
}

# A video whose first frame is one of the photographs, so the thumbnailer extracts something worth
# looking at instead of a black frame.
write_video() {
    local out=$1 still=$2 seconds=$3
    ffmpeg -loglevel error -y -loop 1 -i "$still" \
        -f lavfi -i "sine=frequency=180:sample_rate=48000:duration=${seconds}" \
        -t "$seconds" -vf "scale=1920:-2,format=yuv420p" -r 30 \
        -c:v libx264 -preset veryfast -crf 26 -c:a aac -b:a 128k -shortest \
        "$out" || fail "ffmpeg could not write $out"
}

# ------------------------------------------------------------------ building the stage

require_tools() {
    local tool
    for tool in magick ffmpeg bsdtar zip 7z groff; do
        command -v "$tool" >/dev/null || fail "$tool is missing, so the stage set cannot be built"
    done
}

# The denied directory is made unreadable on purpose, so it has to be opened again before anything
# can walk into it to remove it. Guarded like every other path here, and never with sudo: it is
# owned by whoever ran the build, so a plain chmod is enough.
unlock_denied() {
    [ -e "$denied" ] || return 0
    sandbox_require "$denied"
    chmod 0700 "$SANDBOX_PATH" || fail "could not reopen $SANDBOX_PATH"
}

# Every photograph, rendered in parallel. Each worker is a fresh run of this file in scene mode with
# MAGICK_THREAD_LIMIT=1, because magick threads internally and oversubscribing 12 cores with 12
# threads each is slower than running ten single-threaded renders.
render_all_photos() {
    local index=0 palette_index variant
    local plan=$sandbox/.render-plan
    : > "$plan"
    for (( index = 0; index < photo_count; index++ )); do
        palette_index=$(( index % ${#palettes[@]} ))
        variant=$(( index / ${#palettes[@]} ))
        printf '%s %s %s\n' "$photo_dir/$index.jpg" "$palette_index" "$variant" >> "$plan"
    done
    MAGICK_THREAD_LIMIT=1 xargs -P "$jobs" -L 1 "$self" scene < "$plan" \
        || fail "at least one photograph failed to render"
    rm -f "$plan"
}

# A photograph placed under the name a person would have given it. Reflink, so the album and the
# camera roll can share extents and still carry their own dates.
place_photo() {
    local index=$1 destination=$2
    cp --reflink=auto "$photo_dir/$(( index % photo_count )).jpg" "$destination" \
        || fail "could not place $destination"
}

# The path of one rendered photograph, wrapped into the set that actually exists. A fixed index here
# breaks every build that lowers FLEA_PHOTO_COUNT, which is what a fast rebuild between takes does.
photo_at() {
    printf '%s/%s.jpg' "$photo_dir" "$(( $1 % photo_count ))"
}

# Dates every file directly inside one directory, walking forward from a start. A copy carries the
# time it was made, so without this every album is a column of identical timestamps dated today,
# which is the single fastest way to make a real directory look like a fixture.
spread_dates() {
    local directory=$1 start=$2 step_minutes=$3
    local epoch index=0 entry
    epoch=$(date -d "$start" +%s) || fail "could not read the start date $start"
    for entry in "$directory"/*; do
        [ -e "$entry" ] || continue
        touch -d "@$(( epoch + index * step_minutes * 60 ))" "$entry" \
            || fail "could not date $entry"
        index=$(( index + 1 ))
    done
}

build_projects() {
    local base="$stage/Projects/Lantern"
    mkdir -p "$base/brand/icons" "$base/shots" "$base/src"

    local i
    for i in "${!shot_names[@]}"; do
        place_photo $(( 24 + i )) "$base/shots/${shot_names[i]}"
    done

    # Flat graphics rather than scenes: a logo is not a photograph and drawing one as a scene would
    # be the tell. These also give the grid something that is not a landscape.
    magick -size 1200x1200 xc:"#0e1112" -fill "#b38956" \
        -draw "polygon 600,180 980,820 220,820" -draw "circle 600,700 600,560" \
        "$base/brand/lantern-logo-dark.png"
    magick -size 1200x1200 xc:"#f4f1ea" -fill "#1b1a17" \
        -draw "polygon 600,180 980,820 220,820" -draw "circle 600,700 600,560" \
        "$base/brand/lantern-logo-light.png"
    magick -size 1400x400 xc:"#f4f1ea" \
        -fill "#b38956" -draw "rectangle 0,0 200,400" \
        -fill "#7a6a52" -draw "rectangle 200,0 400,400" \
        -fill "#3f4a3a" -draw "rectangle 400,0 600,400" \
        -fill "#26303a" -draw "rectangle 600,0 800,400" \
        -fill "#1b1a17" -draw "rectangle 800,0 1000,400" \
        -fill "#c9c2b4" -draw "rectangle 1000,0 1200,400" \
        -fill "#e8e3d8" -draw "rectangle 1200,0 1400,400" \
        "$base/brand/palette.png"
    for i in 16 32 64 128; do
        magick "$base/brand/lantern-logo-dark.png" -resize ${i}x${i} "$base/brand/icons/lantern-mark-${i}.png"
    done

    cat > "$base/src/main.rs" <<'RUST'
use crate::render::{Frame, Renderer};
use std::path::PathBuf;

// The entry point does three things and delegates the rest: read the config, build a renderer,
// and hand it every frame the config asked for.
fn main() -> Result<(), Box<dyn std::error::Error>> {
    let config = Config::load(PathBuf::from("config.toml"))?;
    let mut renderer = Renderer::new(config.width, config.height);

    for index in 0..config.frames {
        let frame = Frame::at(index, config.step);
        renderer.draw(&frame)?;
        renderer.write(&config.output.join(format!("frame-{index:04}.png")))?;
    }

    println!("wrote {} frames", config.frames);
    Ok(())
}
RUST
    cat > "$base/src/render.rs" <<'RUST'
// One job: turn a frame description into pixels. Nothing here touches the filesystem except write.
pub struct Renderer {
    width: u32,
    height: u32,
    buffer: Vec<u8>,
}

impl Renderer {
    pub fn new(width: u32, height: u32) -> Self {
        let buffer = vec![0u8; (width * height * 4) as usize];
        Self { width, height, buffer }
    }

    pub fn draw(&mut self, frame: &Frame) -> Result<(), RenderError> {
        if frame.is_empty() {
            return Err(RenderError::EmptyFrame);
        }
        self.clear();
        for shape in frame.shapes() {
            self.fill(shape);
        }
        Ok(())
    }
}
RUST
    cat > "$base/src/config.toml" <<'TOML'
# Render settings. Width and height are pixels; step is the angle advanced per frame, in degrees.
width = 1920
height = 1080
frames = 240
step = 1.5
output = "out/frames"

[quality]
samples = 8
denoise = true
gamma = 2.2

[export]
format = "png"
compression = 6
TOML
    cat > "$base/src/package.json" <<'JSON'
{
  "name": "lantern-preview",
  "version": "0.4.2",
  "private": true,
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "check": "tsc --noEmit"
  },
  "dependencies": {
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "typescript": "^5.5.4",
    "vite": "^5.4.0"
  }
}
JSON

    cat > "$base/README.md" <<'MD'
# Lantern

A small renderer for turntable sequences. It reads `config.toml`, draws the frames
described there, and writes them as numbered PNGs.

## Running it

    cargo run --release

Output lands in `out/frames`. The directory is created if it is not already there.

## Layout

- `src/main.rs` reads the config and drives the loop.
- `src/render.rs` turns a frame description into pixels.
- `brand/` holds the marks and the palette.
- `shots/` holds the photography for the handoff.

## Notes

The renderer is deliberately single-threaded. Frames are independent, so the easy
win is to run several processes rather than to thread the inner loop, and that
keeps the drawing code readable.
MD
    cat > "$base/changelog.md" <<'MD'
# Changelog

## 0.4.2

- Frame numbering is zero-padded to four digits, so a directory sorts correctly.
- The output directory is created rather than assumed.

## 0.4.1

- Fixed a division by zero when `frames` was set to 1.

## 0.4.0

- Added the `[quality]` block and wired `samples` through to the renderer.
- Removed the old `--fast` flag, which did the same thing less clearly.

## 0.3.0

- First pass at the turntable step, replacing the hand-written angle table.
MD

    write_contract "$base/Lantern brand guidelines and asset handoff, second revision (final).pdf"

    spread_dates "$base/shots" "2026-06-18 10:31" 14
    spread_dates "$base/brand" "2026-02-11 11:05" 95
    spread_dates "$base/brand/icons" "2026-02-13 16:22" 3
    spread_dates "$base/src" "2026-03-04 14:52" 47
}

build_photos() {
    mkdir -p "$stage/Photos/Iceland, March" "$stage/Photos/Studio sessions" "$stage/Photos/Camera roll"
    local i
    for i in "${!iceland_names[@]}"; do
        place_photo "$i" "$stage/Photos/Iceland, March/${iceland_names[i]}"
    done
    for i in "${!studio_names[@]}"; do
        place_photo $(( 40 + i )) "$stage/Photos/Studio sessions/${studio_names[i]}"
    done

    spread_dates "$stage/Photos/Iceland, March" "2025-10-02 09:26" 37
    spread_dates "$stage/Photos/Studio sessions" "2026-04-19 12:03" 21

    # The camera roll: enough rows that the listing takes long enough to draw the crawl, and long
    # enough to scroll for as long as a shot needs. Consecutive frames repeat a scene the way a
    # burst on a real camera does.
    local roll="$stage/Photos/Camera roll"
    for (( i = 0; i < roll_count; i++ )); do
        cp --reflink=auto "$photo_dir/$(( i % photo_count )).jpg" \
            "$roll/$(printf 'IMG_%04d.jpg' $(( 1000 + i )))" || fail "could not build the camera roll"
    done

    # Dates walk forward across the roll so the date column reads as a year of shooting rather than
    # as one instant. Eight frames share a timestamp, which is both what a burst on a real camera
    # looks like and what lets one touch date eight files: per-file, this loop spawned 8000.
    local epoch group_epoch burst=8
    epoch=$(date -d "2025-09-14 08:12" +%s) || fail "could not read the camera roll start date"
    local batch=()
    for (( i = 0; i < roll_count; i++ )); do
        batch+=( "$roll/$(printf 'IMG_%04d.jpg' $(( 1000 + i )))" )
        if [ ${#batch[@]} -eq "$burst" ] || [ $(( i + 1 )) -eq "$roll_count" ]; then
            group_epoch=$(( epoch + (i / burst) * 151 * 60 ))
            touch -d "@$group_epoch" "${batch[@]}" || fail "could not date the camera roll"
            batch=()
        fi
    done
}

build_recordings() {
    local base="$stage/Recordings"
    mkdir -p "$base"
    local cover=$sandbox/.cover.jpg
    magick "$(photo_at 3)" -resize 600x600^ -gravity center -extent 600x600 "$cover"
    write_audio "$base/interview with the harbour master.m4a" 214 48000 "$cover" 196
    write_audio "$base/voice memo 07.mp3" 47 44100 "$cover" 233
    write_audio "$base/rehearsal, take 3.flac" 74 96000 "$cover" 147
    write_video "$base/walk to the lighthouse.mp4" "$(photo_at 1)" 14
    write_video "$base/site visit, second floor.mp4" "$(photo_at 8)" 8
    rm -f "$cover"
}

build_documents() {
    local base="$stage/Documents"
    mkdir -p "$base/notes"
    write_contract "$base/Consulting agreement, countersigned.pdf"
    write_invoice "$base/invoice-0142.pdf"

    {
        printf 'date,category,description,amount,reclaimable\n'
        printf '2026-07-02,Travel,Rail to the coast for the location shoot,84.50,yes\n'
        printf '2026-07-02,Meals,Crew lunch for four,62.15,yes\n'
        printf '2026-07-09,Materials,Mount board and spray adhesive,41.80,yes\n'
        printf '2026-07-14,Equipment,Tripod head replacement,219.00,yes\n'
        printf '2026-07-21,Software,Font licence for three weights,180.00,yes\n'
        printf '2026-08-03,Travel,Parking at the print works,12.00,yes\n'
        printf '2026-08-03,Printing,Press check proofs,96.40,yes\n'
        printf '2026-08-11,Materials,Archival sleeves,28.75,yes\n'
        printf '2026-08-19,Travel,Rail to the client review,71.20,yes\n'
        printf '2026-08-26,Meals,Client dinner after signoff,143.60,no\n'
    } > "$base/expenses Q3.csv"

    {
        printf 'sku,item,shelf,count,condition\n'
        local i shelves=(A1 A2 B1 B2 C1 C2 D1)
        for i in $(seq 1 48); do
            printf 'LS-%04d,Print %s series no %d,%s,%d,%s\n' \
                $(( 1000 + i )) \
                "$( [ $(( i % 3 )) -eq 0 ] && printf 'harbour' || printf 'coast' )" \
                "$i" "${shelves[$(( i % 7 ))]}" $(( 1 + i % 9 )) \
                "$( [ $(( i % 5 )) -eq 0 ] && printf 'needs remount' || printf 'good' )"
        done
    } > "$base/inventory.csv"

    cat > "$base/notes/meeting notes, 14 August.md" <<'MD'
# Review, 14 August

Present: the studio, the client's marketing lead, the print supplier.

## Decisions

- The dark mark is the primary. The light one is for print on uncoated stock only.
- Phase two ships with the templates, not before. The client would rather wait than
  hand out something half-documented.
- Photography stays as shot. No further retouching on the location frames.

## Open

- The press check date depends on the paper arriving. Supplier says the week of the
  25th, and will confirm on Friday.
- Nobody has decided who owns the asset library after handoff. Raised twice now;
  needs an answer before the final invoice.

## Next

- Send the revised schedule by Monday.
- Book the second press check as provisional.
MD
    cat > "$base/notes/reading list.md" <<'MD'
# Reading list

## Started

- *The Shape of Design* - short, and the chapter on craft is worth the whole book.
- *Thinking with Type* - reread. The tables at the back are the useful part.

## Queued

- Something on colour management that is not a manual.
- The archive of that typography journal, if the library still has it.

## Abandoned

- The one about workflow. Three hundred pages to say keep a list.
MD
    cat > "$base/notes/ideas.txt" <<'TXT'
ideas, unsorted

- A print series where each frame is the same view an hour apart. Twenty-four frames,
  one wall. Needs a fixed mount and a week of decent weather, so: not this year.

- The turntable renderer could export a contact sheet directly. It already has every
  frame in memory at the end; writing one more image is almost free.

- Ask about the harbour archive. They have negatives nobody has scanned and they
  said once they would be glad to have them catalogued.

- Stop buying notebooks.
TXT
}

build_archives() {
    local base="$stage/Archives"
    mkdir -p "$base"
    local staging=$sandbox/.archive-staging
    rm -rf "$staging"
    mkdir -p "$staging/press kit/images" "$staging/press kit/text"

    local i
    for i in $(seq 0 11); do
        place_photo $(( 12 + i )) "$staging/press kit/images/press-$(printf '%02d' "$i").jpg"
    done
    printf 'Lantern press kit\n\nContact the studio for high resolution files.\n' \
        > "$staging/press kit/text/about.txt"
    printf 'Lantern, a renderer for turntable sequences.\n' > "$staging/press kit/text/one-liner.txt"
    for i in $(seq 1 26); do
        printf 'Caption for plate %d.\n' "$i" > "$staging/press kit/text/caption-$(printf '%02d' "$i").txt"
    done
    ( cd "$staging" && zip -q -r "$base/press-kit.zip" "press kit" ) || fail "zip failed"

    mkdir -p "$staging/site/css" "$staging/site/js"
    printf 'body { margin: 0; font: 16px/1.5 system-ui, sans-serif; }\n' > "$staging/site/css/main.css"
    printf 'document.title = "Lantern";\n' > "$staging/site/js/app.js"
    printf '<!doctype html>\n<title>Lantern</title>\n<h1>Lantern</h1>\n' > "$staging/site/index.html"
    bsdtar -a -c -f "$base/site-backup.tar.gz" -C "$staging" site || fail "bsdtar failed"

    mkdir -p "$staging/old designs"
    for i in $(seq 0 7); do
        place_photo $(( 30 + i )) "$staging/old designs/concept-$(printf '%02d' "$i").jpg"
    done
    bsdtar -a -c -f "$base/old-designs.tar.zst" -C "$staging" "old designs" || fail "bsdtar failed"

    mkdir -p "$staging/raw scans"
    for i in $(seq 0 5); do
        place_photo $(( 48 + i )) "$staging/raw scans/scan-$(printf '%03d' "$i").jpg"
    done
    ( cd "$staging" && 7z a -bd -bso0 "$base/raw-scans.7z" "raw scans" >/dev/null ) || fail "7z failed"
    rm -rf "$staging"
}

build_downloads() {
    local base="$stage/Downloads"
    mkdir -p "$base"

    # The transfer demo. A WAV because it is uncompressed, so its size on disk is its size on the
    # size column, and because noise does not compress: a file the filesystem can squash would copy
    # far faster than the status line's byte progress is meant to show.
    local seconds=$(( big_file_mb * 1024 * 1024 / (96000 * 2 * 3) ))
    ffmpeg -loglevel error -y -f lavfi -i "anoisesrc=amplitude=0.4:sample_rate=96000:duration=${seconds}" \
        -ac 2 -c:a pcm_s24le "$base/field recording master.wav" || fail "ffmpeg could not write the master"

    # Over the 1 MiB preview cap, so the text frame says so instead of drawing a truncated file.
    {
        local i
        for i in $(seq 1 24000); do
            printf '2026-08-%02d %02d:%02d:%02d  request id=%06d  route=/api/render  status=%d  %dms\n' \
                $(( 1 + i % 28 )) $(( i % 24 )) $(( i % 60 )) $(( (i * 7) % 60 )) \
                "$i" "$( [ $(( i % 40 )) -eq 0 ] && printf 500 || printf 200 )" $(( 12 + i % 300 ))
        done
    } > "$base/server.log"

    printf 'Lantern 0.4.2 release build.\n' > "$base/notes.txt"
    bsdtar -a -c -f "$base/lantern-0.4.2.tar.xz" -C "$stage/Projects/Lantern" src \
        || fail "bsdtar failed on the installer"
}

# The three states that are not content: an empty directory, a directory that refuses to be read,
# and a link whose target is not there.
build_states() {
    mkdir -p "$stage/Scans"

    mkdir -p "$denied"
    printf 'not readable by design\n' > "$denied/.placeholder"
    touch -d '2026-05-22 09:40' "$denied" || fail "could not date $denied"
    chmod 0000 "$denied" || fail "could not close $denied"

    # A link to a drive that is not mounted. The target does not exist and never will, so nothing
    # that follows it can reach anything: this is a dangling name, not a route out of the sandbox.
    ln -s /mnt/archive-nas/2026 "$stage/Archive drive"
}

# Dates spread across fourteen months. A column of identical timestamps is the fastest way to make a
# real directory look like a fixture, and sorting by date has to visibly reorder the listing.
set_dates() {
    local when path
    while read -r when path; do
        [ -e "$stage/$path" ] || [ -L "$stage/$path" ] || continue
        touch -h -d "$when" "$stage/$path"
    done <<'DATES'
2025-11-03T09:14 Projects
2026-01-22T16:40 Projects/Lantern
2026-02-11T11:05 Projects/Lantern/brand
2026-03-04T14:52 Projects/Lantern/src
2026-06-18T10:31 Projects/Lantern/shots
2026-08-12T17:09 Projects/Lantern/README.md
2026-08-27T13:44 Projects/Lantern/changelog.md
2025-09-14T08:12 Photos
2025-10-02T19:26 Photos/Iceland, March
2026-04-19T12:03 Photos/Studio sessions
2026-09-01T18:40 Photos/Camera roll
2026-05-30T07:48 Recordings
2026-05-30T07:48 Recordings/interview with the harbour master.m4a
2026-06-02T21:15 Recordings/voice memo 07.mp3
2026-07-11T20:37 Recordings/rehearsal, take 3.flac
2026-07-24T15:58 Recordings/walk to the lighthouse.mp4
2026-08-05T09:22 Recordings/site visit, second floor.mp4
2026-08-14T18:30 Documents
2026-08-14T18:30 Documents/notes
2026-06-27T10:11 Documents/Consulting agreement, countersigned.pdf
2026-08-12T09:03 Documents/invoice-0142.pdf
2026-07-31T16:19 Documents/expenses Q3.csv
2026-08-22T11:47 Documents/inventory.csv
2026-08-14T18:30 Documents/notes/meeting notes, 14 August.md
2026-03-16T22:05 Documents/notes/reading list.md
2026-08-29T07:33 Documents/notes/ideas.txt
2026-02-08T13:27 Archives
2026-02-08T13:27 Archives/press-kit.zip
2025-12-19T15:41 Archives/site-backup.tar.gz
2025-10-27T18:52 Archives/old-designs.tar.zst
2026-01-09T12:16 Archives/raw-scans.7z
2026-08-30T20:44 Downloads
2026-08-30T20:44 Downloads/field recording master.wav
2026-08-31T08:57 Downloads/server.log
2026-08-28T14:02 Downloads/lantern-0.4.2.tar.xz
2026-08-31T19:23 Downloads/notes.txt
2026-08-19T10:08 Scans
2026-07-07T17:31 Archive drive
DATES
}

# ------------------------------------------------------------------ reporting

show_tree() {
    [ -d "$stage" ] || fail "no stage set at $stage; run build first"
    printf 'STAGE %s\n\n' "$stage"
    # The camera roll is thousands of rows, so it is summarised rather than printed. The denied
    # directory refuses find as designed, so its error is dropped and it is reported by name below.
    ( cd "$stage" && find . -maxdepth 2 -not -path './Photos/Camera roll/*' 2>/dev/null \
        | LC_ALL=C sort | sed 's|^\./||' )
    printf '\nPhotos/Camera roll: %s entries\n' "$(ls -A "$stage/Photos/Camera roll" | wc -l)"
    printf 'Client vault:       %s (unreadable on purpose)\n' "$(stat -c '%A' "$denied")"
    printf 'Archive drive:      %s\n' "$(readlink "$stage/Archive drive") (a target that is not there)"
}

show_weight() {
    printf '\napparent size, du -sh --apparent-size: %s\n' \
        "$(du -sh --apparent-size "$sandbox" 2>/dev/null | cut -f1)"
    # du reads st_blocks and charges every reflink clone for extents it only shares, so on a set
    # that is mostly clones du is not the weight. What deleting it gives back is what btrfs calls
    # exclusive plus the shared set counted once, which is the line below.
    if command -v btrfs >/dev/null; then
        printf 'on disk, btrfs filesystem du -s:\n'
        btrfs filesystem du -s "$sandbox" 2>/dev/null | sed 's/^/  /'
    fi
    printf 'distinct photographs rendered:         %s\n' "$photo_count"
    printf 'files in the stage set:                %s\n' "$(find "$stage" 2>/dev/null | wc -l)"
}

# The check the operator asked for and the only one that settles it: look at the photographs at the
# sizes Flea actually draws them. Theme.grid.iconSize is 46 and a list row's mark is 23; the 256 is
# the freedesktop cache the preview column draws, which is the largest Flea ever shows an image.
build_montage() {
    [ -d "$photo_dir" ] || fail "no photographs at $photo_dir; run build first"
    local out=$sandbox/montage
    mkdir -p "$out"
    local size
    for size in 23 46 256; do
        magick montage "$photo_dir"/*.jpg -tile 12x -geometry ${size}x${size}+3+3 \
            -background "#0e1112" "$out/photos-${size}px.png" || fail "montage failed at ${size}px"
    done
    printf 'MONTAGE %s\n' "$out"
    ls -l "$out"
}

# ------------------------------------------------------------------ the subcommands

self=$(cd "$(dirname "$0")" && pwd)/$(basename "$0")
photo_dir=$sandbox/.photos
photo_count=${FLEA_PHOTO_COUNT:-60}

do_build() {
    require_tools
    local started=$(date +%s)
    unlock_denied
    sandbox_make "$sandbox"
    mkdir -p "$photo_dir" "$stage"

    printf 'rendering %s photographs on %s workers\n' "$photo_count" "$jobs"
    render_all_photos
    printf 'placing the stage set\n'
    build_projects
    build_photos
    build_recordings
    build_documents
    build_archives
    build_downloads
    build_states
    set_dates

    show_tree
    show_weight
    printf 'built in %s s\n' "$(( $(date +%s) - started ))"
}

case "${1:-}" in
    build)   do_build ;;
    list)    show_tree; show_weight ;;
    montage) build_montage ;;
    remove)  unlock_denied; sandbox_remove "$sandbox"; printf 'removed %s\n' "$sandbox" ;;
    # Not for a person to run: one worker of the parallel render, re-entered by xargs.
    scene)   render_photo "$2" "$3" "$4" ;;
    *)       fail "usage: $(basename "$0") build|list|montage|remove" ;;
esac
