diff --git a/.dockerignore b/.dockerignore index 4bdce95..52048c4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,7 @@ # Gallery photos are mounted at runtime, not baked into the image app/images/** !app/images/.gitkeep + +# Raw uploads stay on the host only +upload/** +!upload/.gitkeep diff --git a/.env.example b/.env.example index 40c2d57..dd1b20d 100644 --- a/.env.example +++ b/.env.example @@ -12,7 +12,7 @@ SMTP_TLS=auto # HTTP listen address ADDR=:8080 -# Homepage hero (path under app/images/) +# Homepage hero (path under app/images/, e.g. connectionmachine/photo.jpg) HERO_IMAGE=connectionmachine/20220723_231556.jpg # Gallery admin at /admin/ (both required to enable) diff --git a/.gitignore b/.gitignore index e24d5f8..3af679e 100644 --- a/.gitignore +++ b/.gitignore @@ -25,7 +25,11 @@ go.work /app/tmp/ /app/bin/ -# Gallery photos and generated derivatives (not in git) +# Raw media staging (sync to app/images/ with make sync-media) +/upload/** +!/upload/.gitkeep + +# Published gallery (served at /images/; not in git) /app/images/** !/app/images/.gitkeep diff --git a/Dockerfile b/Dockerfile index bbdceb0..889458d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,5 +23,6 @@ COPY app/static ./static EXPOSE 8080 ENV ADDR=:8080 +ENV IMAGES_DIR=/app/images CMD ["./server"] diff --git a/Makefile b/Makefile index 126dd61..0a8386a 100644 --- a/Makefile +++ b/Makefile @@ -6,9 +6,9 @@ export GOMODCACHE ?= $(HOME)/go/pkg/mod export GOCACHE ?= $(HOME)/.cache/go-build # One-off commands in the dev image (same caches and ./app mount as `make dev`) -DEV_RUN := $(COMPOSE) run --rm --no-deps dev +DEV_RUN := $(COMPOSE) run --rm --no-deps -T dev -.PHONY: dev build up down generate tidy logs +.PHONY: dev build up down generate tidy logs thumbs sync-media publish convert-videos .DEFAULT_GOAL := dev @@ -34,3 +34,17 @@ tidy: logs: $(COMPOSE) logs -f dev + +# Pre-build gallery thumbnails and video poster JPEGs (ffmpeg in dev image). +thumbs: + $(DEV_RUN) go run ./cmd/server -thumbs + +# Copy photos and convert videos from upload/ into app/images/. +sync-media: + $(DEV_RUN) sh -c 'UPLOAD_DIR=/upload IMAGES_DIR=/app/images ./scripts/sync-media.sh $(ARGS)' + +# sync-media then rebuild thumbs/hero derivatives under app/images/. +publish: sync-media thumbs + +# Alias for sync-media (videos are converted as part of sync). +convert-videos: sync-media diff --git a/README.md b/README.md index 2187087..48af4e3 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,9 @@ make dev # live reload → http://localhost:7331 | `make generate` | Run `templ generate` in dev container | | `make tidy` | Run `go mod tidy` in dev container | | `make logs` | Follow dev container logs | +| `make sync-media` | Copy photos and convert videos from `upload/` → `app/images/` | +| `make thumbs` | Generate gallery thumbnails and video posters under `app/images/` | +| `make publish` | `sync-media` then `thumbs` | ## Features @@ -31,7 +34,7 @@ make dev # live reload → http://localhost:7331 - Gallery loads via HTMX (`hx-get="/gallery"`) - Lightbox modal with previous/next navigation - Contact form with SMTP relay (HTMX submit, no page reload) -- Serves photos from `app/images/` (not in git — mount or copy locally) +- Serves gallery media from `app/images/` (published copy; not in git) - Password-protected gallery admin at `/admin/` ## Gallery admin @@ -60,13 +63,13 @@ If SMTP is not configured, the page shows a mailto fallback instead of the form. ## Dev container -`make dev` mounts `./app` and your host Go caches (`~/go/pkg/mod`, `~/.cache/go-build` by default). Override with `GOMODCACHE` / `GOCACHE` in `.env`. +`make dev` mounts `./app` and `./upload` (raw media staging) plus your host Go caches (`~/go/pkg/mod`, `~/.cache/go-build` by default). Override with `GOMODCACHE` / `GOCACHE` in `.env`. **Use [http://localhost:7331](http://localhost:7331)** in the browser — the templ proxy injects live reload on `.templ`, `.go`, and `.css` changes. Port 8080 hits the app directly without auto-reload. Only run **`dev`** or **`website`** at a time if you map both to the same host ports. -Production `website` mounts `./app/images` into the container (photos stay on the host). It joins the external `caddy` Docker network for reverse proxy labels. +Production `website` mounts `./app/images` into the container. It joins the external `caddy` Docker network for reverse proxy labels. ## Project layout @@ -79,17 +82,22 @@ app/ internal/handlers/ Routes and HTMX partials templates/ templ components (.templ → generated Go) static/ CSS - images/ Gallery photos (served at /images/) + images/ Published gallery (served at /images/) +upload/ Raw media — run `make sync-media` to publish into app/images/ Dockerfile Production image Dockerfile.dev Dev image (Go + templ + Air) docker-compose.yaml .env.example ``` -## Gallery images +## Gallery media -On startup the server generates missing derivatives under `app/images/thumbs/` (and `hero/` for JPEGs only). Photos get resized JPEG thumbs; videos (`.mp4`, `.webm`, `.mov`) get a poster frame JPEG via `ffmpeg` when installed. The lightbox plays full videos or shows full-resolution photos. Derivatives are gitignored and rebuilt when source files are newer. +1. Drop photos and videos into repo-root **`upload/`** (album folders, same layout as the gallery). +2. Run **`make sync-media`** — JPEGs are copied; videos are converted to H.264 MP4 in **`app/images/`**. +3. Run **`make thumbs`** (or **`make publish`** for both steps) — builds `thumbs/` and `hero/` derivatives under `app/images/`. + +The site serves files from `app/images/` at `/images/…`. Thumbnails and hero images are gitignored and rebuilt when sources are newer. ## Deploy notes -First request after deploy may take a moment while thumbnails are generated under the mounted `app/images/` directory. +After adding media, run `make publish` on the host before or after deploy. First request may take a moment while any missing thumbnails are generated. diff --git a/app/cmd/server/main.go b/app/cmd/server/main.go index e9c970c..56a6926 100644 --- a/app/cmd/server/main.go +++ b/app/cmd/server/main.go @@ -22,7 +22,7 @@ func main() { log.Fatal(err) } - imagesDir := filepath.Join(root, "images") + imagesDir := envOr("IMAGES_DIR", filepath.Join(root, "images")) staticDir := filepath.Join(root, "static") if *thumbsOnly { @@ -54,6 +54,7 @@ func main() { srv.RegisterRoutes(mux) addr := envOr("ADDR", ":8080") + log.Printf("gallery images: %s", imagesDir) log.Printf("Technical Kiwi website listening on %s", addr) if err := http.ListenAndServe(addr, middleware.Gzip(mux)); err != nil { log.Fatal(err) diff --git a/app/dev.sh b/app/dev.sh index 320595d..0c54689 100755 --- a/app/dev.sh +++ b/app/dev.sh @@ -2,7 +2,7 @@ set -e # Browser: http://localhost:7331 (proxy injects auto-reload) -# App: http://127.0.0.1:8080 (internal, via proxy) +# App: http://127.0.0.1:8080 (direct, same app) exec templ generate --watch \ --proxy="http://127.0.0.1:8080" \ --proxybind="0.0.0.0" \ diff --git a/app/images/.gitkeep b/app/images/.gitkeep index 8b13789..e69de29 100644 --- a/app/images/.gitkeep +++ b/app/images/.gitkeep @@ -1 +0,0 @@ - diff --git a/app/internal/gallery/gallery.go b/app/internal/gallery/gallery.go index 6272ac7..fe1cbde 100644 --- a/app/internal/gallery/gallery.go +++ b/app/internal/gallery/gallery.go @@ -16,7 +16,7 @@ type Image struct { Collection string // event subfolder label, e.g. Anniversary CollectionKey string // slug for filters Filename string // basename - URL string + URL string // served file URL (/images/…) ThumbURL string HeroURL string IsVideo bool diff --git a/app/internal/gallery/media.go b/app/internal/gallery/media.go index c8f21d3..712e104 100644 --- a/app/internal/gallery/media.go +++ b/app/internal/gallery/media.go @@ -15,6 +15,9 @@ func isVideo(name string) bool { } func isGalleryMedia(name string) bool { + if isWebDerivative(name) { + return false + } return isJPEG(name) || isVideo(name) } @@ -34,3 +37,7 @@ func videoPosterRel(rel string) string { } return strings.TrimSuffix(rel, ext) + ".jpg" } + +func isWebDerivative(name string) bool { + return strings.HasSuffix(strings.ToLower(name), ".web.mp4") +} diff --git a/app/internal/gallery/media_test.go b/app/internal/gallery/media_test.go index a39b7f7..0b84d5e 100644 --- a/app/internal/gallery/media_test.go +++ b/app/internal/gallery/media_test.go @@ -12,6 +12,15 @@ func TestVideoPosterRel(t *testing.T) { } } +func TestIsWebDerivative(t *testing.T) { + if !isWebDerivative("clip.web.mp4") { + t.Fatal("expected web derivative") + } + if isGalleryMedia("clip.web.mp4") { + t.Fatal("web derivative must not be listed") + } +} + func TestList_includesVideo(t *testing.T) { dir := t.TempDir() album := filepath.Join(dir, "portal") diff --git a/app/internal/handlers/handlers.go b/app/internal/handlers/handlers.go index 987d7dc..ebaae43 100644 --- a/app/internal/handlers/handlers.go +++ b/app/internal/handlers/handlers.go @@ -73,8 +73,6 @@ func New(imagesDir, staticDir string, mailCfg *mail.Config) (*Server, error) { } else { log.Print("gallery thumbnails: ready") } - // Thumbnail URLs are resolved during gallery.List. Re-list after derivative - // generation so newly-created thumbs appear immediately on first load. if refreshed, err := gallery.List(imagesDir); err != nil { log.Printf("gallery refresh after thumbnails: %v", err) } else { diff --git a/app/scripts/convert-videos.sh b/app/scripts/convert-videos.sh new file mode 100755 index 0000000..898d974 --- /dev/null +++ b/app/scripts/convert-videos.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Deprecated alias — use sync-media.sh (upload/ → app/images/). +exec "$(dirname "$0")/sync-media.sh" "$@" diff --git a/app/scripts/sync-media.sh b/app/scripts/sync-media.sh new file mode 100755 index 0000000..d6aa48e --- /dev/null +++ b/app/scripts/sync-media.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# Copy photos and convert videos from upload/ into app/images/ (gallery source). +# Videos are always re-encoded for browser playback (H.264 + AAC, yuv420p, faststart). +# +# Usage (from app/): +# ./scripts/sync-media.sh +# UPLOAD_DIR=/upload IMAGES_DIR=/app/images ./scripts/sync-media.sh +# +# Requires: ffmpeg (for videos) + +set -euo pipefail + +upload_dir="${UPLOAD_DIR:-../upload}" +images_dir="${IMAGES_DIR:-images}" +dry_run=0 +force=0 +max_width=1920 +crf=23 +preset=medium + +usage() { + sed -n '2,8p' "$0" + echo "" + echo "Options:" + echo " --upload-dir DIR Raw media source (default: ../upload)" + echo " --images-dir DIR Gallery output (default: images)" + echo " --dry-run Print actions only" + echo " --force Re-copy / re-encode even if output looks up to date" + echo " --max-width N Scale down wide video (default: 1920)" + echo " --crf N H.264 quality (default: 23)" + echo " -h, --help Show this help" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --upload-dir) upload_dir=$2; shift 2 ;; + --images-dir) images_dir=$2; shift 2 ;; + --dry-run) dry_run=1; shift ;; + --force) force=1; shift ;; + --max-width) max_width=$2; shift 2 ;; + --crf) crf=$2; shift 2 ;; + -h | --help) usage; exit 0 ;; + *) echo "Unknown option: $1" >&2; usage; exit 1 ;; + esac +done + +if ! command -v ffmpeg >/dev/null 2>&1; then + echo "ffmpeg is required." >&2 + exit 1 +fi + +if [[ ! -d "$upload_dir" ]]; then + echo "Upload directory not found: $upload_dir" >&2 + exit 1 +fi + +mkdir -p "$images_dir" + +is_video_ext() { + case "${1,,}" in + .mp4 | .mov | .webm) return 0 ;; + *) return 1 ;; + esac +} + +is_jpeg_ext() { + case "${1,,}" in + .jpg | .jpeg) return 0 ;; + *) return 1 ;; + esac +} + +skip_name() { + local base=${1##*/} + [[ "$base" == .* ]] && return 0 + [[ "$base" == *.web.mp4 ]] && return 0 + [[ "$base" == *.converting.mp4 ]] && return 0 + [[ "$base" == *.part ]] && return 0 + return 1 +} + +skip_path() { + case "$1" in + */thumbs/* | */hero/*) return 0 ;; + esac + return 1 +} + +needs_convert() { + local src=$1 dst=$2 + if [[ -f "$dst" ]] && [[ "$force" -eq 0 ]] && [[ "$dst" -nt "$src" ]]; then + return 1 + fi + return 0 +} + +copy_one() { + local src=$1 dst=$2 rel + rel=${src#"$upload_dir"/} + rel=${rel#/} + echo "→ copy $rel" + + if [[ "$dry_run" -eq 1 ]]; then + echo " would write: ${dst#"$images_dir"/}" + return 0 + fi + + mkdir -p "$(dirname "$dst")" + cp -p "$src" "$dst" + echo " wrote: ${dst#"$images_dir"/}" +} + +convert_one() { + local src=$1 dst=$2 + local dir name tmp rel + dir=$(dirname "$dst") + name=$(basename "$dst" .mp4) + tmp="$dir/.${name}.converting.mp4" + rel=${src#"$upload_dir"/} + rel=${rel#/} + + echo "→ convert $rel" + + if [[ "$dry_run" -eq 1 ]]; then + echo " would write: ${dst#"$images_dir"/}" + return 0 + fi + + mkdir -p "$dir" + rm -f "$tmp" + trap 'rm -f "$tmp"' RETURN + + ffmpeg -nostdin -hide_banner -loglevel error -y \ + -i "$src" \ + -vf "scale=min(${max_width}\\,iw):-2" \ + -c:v libx264 -profile:v high -level:v 4.0 -pix_fmt yuv420p \ + -crf "$crf" -preset "$preset" \ + -c:a aac -b:a 128k -ac 2 \ + -movflags +faststart \ + -f mp4 \ + "$tmp" + + mv -f "$tmp" "$dst" + trap - RETURN + echo " wrote: ${dst#"$images_dir"/}" +} + +copied=0 +converted=0 +skipped=0 + +while IFS= read -r -d '' src; do + base=$(basename "$src") + ext=".${base##*.}" + + if skip_name "$base" || skip_path "$src"; then + continue + fi + + rel=${src#"$upload_dir"/} + rel=${rel#/} + dst="$images_dir/$rel" + + if is_jpeg_ext "$ext"; then + if [[ -f "$dst" ]] && [[ "$force" -eq 0 ]] && [[ "$dst" -nt "$src" ]]; then + ((skipped++)) || true + continue + fi + copy_one "$src" "$dst" + ((copied++)) || true + continue + fi + + if ! is_video_ext "$ext"; then + continue + fi + + dst="$images_dir/${rel%.*}.mp4" + + if ! needs_convert "$src" "$dst"; then + ((skipped++)) || true + continue + fi + + convert_one "$src" "$dst" + ((converted++)) || true +done < <(find "$upload_dir" -type f \( -iname '*.jpg' -o -iname '*.jpeg' -o -iname '*.mov' -o -iname '*.mp4' -o -iname '*.webm' \) -print0) + +echo "" +echo "Done. Copied: $copied, converted: $converted, skipped: $skipped." +if [[ "$dry_run" -eq 1 ]]; then + echo "(dry run — no files changed)" +fi diff --git a/app/static/style.css b/app/static/style.css index 38a6d44..608351e 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -311,21 +311,26 @@ main { background: var(--surface); } -.gallery-video-badge { +.gallery-media-icon { position: absolute; - left: 0.35rem; - bottom: 0.35rem; - padding: 0.15rem 0.4rem; - border-radius: 3px; - background: rgba(0, 0, 0, 0.65); + top: 0.5rem; + right: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + border-radius: 4px; + background: rgba(20, 16, 12, 0.8); color: var(--text); - font-family: var(--mono); - font-size: 0.65rem; - letter-spacing: 0.04em; - text-transform: uppercase; pointer-events: none; } +.gallery-media-icon svg { + width: 1rem; + height: 1rem; +} + .gallery-item:hover img { transform: scale(1.04); } @@ -524,54 +529,30 @@ main { background: #000; } -.modal-video { - position: relative; - width: 100%; -} - -.modal-figure img, -.modal-figure video { +.modal-figure > img { display: block; width: 100%; max-height: 75vh; - min-height: 12rem; object-fit: contain; border-radius: 8px; background: #000; } -.modal-figure video.video-broken { - display: none; -} - -.video-unavailable { - margin: 0; - padding: 1.25rem; - text-align: center; - color: var(--text-muted); - background: #000; - border-radius: 8px; - min-height: 12rem; +.modal-video-stack { display: flex; flex-direction: column; - align-items: center; - justify-content: center; gap: 0.75rem; + width: 100%; } -.video-unavailable[hidden] { - display: none !important; -} - -.video-unavailable a { - color: var(--accent); -} - -.modal-figure figcaption { - margin-top: 0.75rem; - color: var(--text-muted); - font-family: var(--mono); - font-size: 0.85rem; +.modal-video-stack video { + display: block; + width: 100%; + max-height: 75vh; + min-height: 3.5rem; + object-fit: contain; + border-radius: 8px; + background: #000; } .modal-nav { @@ -742,14 +723,25 @@ main { min-height: 0; } - .modal-figure img, - .modal-figure video { + .modal-figure > img { flex: 1; min-height: 0; max-height: none; object-fit: contain; } + .modal-video-stack { + flex: 1; + min-height: 0; + justify-content: center; + } + + .modal-video-stack video { + flex: 1; + min-height: 0; + max-height: none; + } + .modal-nav { flex-shrink: 0; gap: 0.5rem; diff --git a/app/static/video-test.html b/app/static/video-test.html new file mode 100644 index 0000000..d2a8947 --- /dev/null +++ b/app/static/video-test.html @@ -0,0 +1,275 @@ + + + + + + Video playback test — Technical Kiwi + + + +
+

Video playback test

+

+ Served from /static/video-test.html. + Use with make dev at + localhost:7331 + or 8080. + Path is under /images/… (copy from gallery Network tab). +

+ +
+ +
+ + + Open URL +
+

+ Query link: ?path=/images/… (encode slashes as %2F if needed) +

+
+ +
+

Player

+ +
+
src
+
readyState
+
dimensions
+
duration
+
networkState
+
error
+
+
+ +
+

Browser codec hints (canPlayType)

+ + + +
MIMEResult
+
+ +
+

Events

+ +
+
+ + + diff --git a/app/templates/gallery.templ b/app/templates/gallery.templ index 70adca6..702f695 100644 --- a/app/templates/gallery.templ +++ b/app/templates/gallery.templ @@ -33,9 +33,7 @@ templ GalleryGrid(images []gallery.Image) { } else { } - if img.IsVideo { - - } + @GalleryMediaIcon(img.IsVideo) if img.Collection != "" { { img.Collection } } else if img.Album != "" { @@ -49,6 +47,20 @@ templ GalleryGrid(images []gallery.Image) { } } +templ GalleryMediaIcon(isVideo bool) { + +} + templ ImageModal(img gallery.Image, prevPath, nextPath string) {