No description
Find a file
2026-05-30 20:36:33 +00:00
examples web: move web-push sending to webserver; remove push logic from worker; add enable_show_local.php 2026-05-28 18:52:54 +00:00
hooks tools: add authorized-notify hook 2026-05-28 18:56:42 +00:00
.gitignore Ignore local media/assets dirs 2026-05-28 15:05:23 +00:00
movmaker.py Fix sorting: parse full timestamp from filename for videos 2026-05-30 20:36:33 +00:00
mvlog_worker.py mvlog_worker: upload audio files to remote in-dir; update README 2026-05-29 08:05:23 +00:00
package-lock.json Send web push notifications from worker 2026-05-26 13:48:33 +00:00
package.json Send web push notifications from worker 2026-05-26 13:48:33 +00:00
README.md mvlog_worker: upload audio files to remote in-dir; update README 2026-05-29 08:05:23 +00:00
send_push.js Send web push notifications from worker 2026-05-26 13:48:33 +00:00

movmaker

Simple Python + ffmpeg movie maker.

Drop pictures, videos, music, and optionally one simple data.txt file into a single flat input directory. Then run one command to create an MP4 video.

No YAML. No required subdirectories.

Requirements

  • Python 3
  • ffmpeg
  • ffprobe usually included with ffmpeg

Install on Debian/Ubuntu/Raspberry Pi OS:

sudo apt install ffmpeg

Fonts

The default title/stamp styling uses these fonts:

  • Bebas Neue for the opening title, date, and location
  • Noto Sans Bold for the bottom-left/bottom-right stamps
  • IBM Plex Sans SemiBold for per-file captions

Both fonts support common Danish and Croatian characters such as æ ø å Æ Ø Å č ć ž š đ Č Ć Ž Š Đ.

Install them locally for the current user:

mkdir -p ~/.local/share/fonts/google
curl -L -o ~/.local/share/fonts/google/BebasNeue-Regular.ttf \
  'https://github.com/google/fonts/raw/main/ofl/bebasneue/BebasNeue-Regular.ttf'
sudo apt install fonts-noto-core fonts-ibm-plex
fc-cache -f

Check that fonts are available:

fc-match 'Bebas Neue'
fc-match 'Noto Sans'
fc-match 'IBM Plex Sans'

Optional Jamendo music download

If the input folder has no audio file, movmaker can download one random track from Jamendo.

Save your Jamendo client ID in either an environment variable:

export JAMENDO_CLIENT_ID='your_client_id_here'

or in a local config file:

mkdir -p ~/.config/movmaker
echo 'JAMENDO_CLIENT_ID=your_client_id_here' > ~/.config/movmaker/jamendo.env
chmod 600 ~/.config/movmaker/jamendo.env

The default music search query is:

cinematic punk rock

Override it with:

python3 movmaker.py input --music-genre "upbeat rock"

Input folder

Example:

input/
  IMG_001.jpg
  IMG_002.png
  holiday_clip.mp4
  music.mp3
  data.txt

Supported file types:

  • Images: .jpg, .jpeg, .png, .webp, .bmp, .tif, .tiff
  • Videos: .mp4, .mov, .mkv, .avi, .webm, .m4v
  • Audio: .mp3, .wav, .m4a, .aac, .flac, .ogg
  • Data: data.txt, info.txt, or title.txt

Files are ordered by filename, so names like this work well:

001.jpg
002.jpg
003.mp4
004.jpg

data.txt

data.txt is optional, but if present every non-empty, non-comment line must use mandatory key: value syntax. Plain positional lines are not supported.

Movie metadata keys:

Title: Paris Trip
Location: Paris, France
Date: May 2024
Description: Longer description shown in MVLog and embedded in final MP4 metadata.

Supported metadata key aliases:

  • title: Title or Name
  • location: Location, Place, or Where
  • date: Date, Dates, or When
  • description: Description, Desc, or Synopsis

If the date entry is omitted, movmaker uses the date from the first picture/video and displays it like:

May 2026

If the location entry is omitted and GPS coordinates are found in the first picture/video that has them, movmaker reverse geocodes them and uses that as the location. It tries to return only city/town/village, country; if no city/town/village is found, it uses just the country.

Any other key: value line is treated as a per-file caption where the key is the exact media filename:

IMG_001.jpg: Eiffel Tower
IMG_002.jpg: Louvre
IMG_003.jpg: First line | Second line

MVLog worker

mvlog_worker.py can be run from this machine by cron or a systemd timer to process MVLog jobs stored on the web machine.

Default paths:

  • remote host: 192.168.0.204
  • remote web root: /var/www/html/mvlog
  • inputs: in-dir/<job>/
  • outputs: out-dir/YYYYMMDD_title.mp4
  • per-job state: in-dir/<job>/.movmaker-state.json

The worker:

  1. scans remote in-dir/
  2. computes a fingerprint from input filenames, sizes, and mtimes
  3. skips jobs whose fingerprint matches a completed state file and whose output exists
  4. copies changed jobs locally
  5. renders with movmaker.py
  6. uploads the MP4 atomically to remote out-dir/
  7. removes the old output if the regenerated filename changed
  8. clears the web metadata cache

Note: The worker now also uploads audio files found in the local input directory back to the webserver's in-dir (the same job subdirectory) after rendering. It skips files that already exist on the server. Supported extensions include: .mp3, .wav, .m4a, .aac, .flac, .ogg, .opus, .wma, .aiff, .aif. When running against a remote host the worker uploads using scp (upload to a temporary filename then move into place); when run with --local it copies files into the remote path. Audio upload failures are reported as warnings and do not prevent publishing the generated MP4. After successful uploads the worker recomputes the job fingerprint and records it in the job state so subsequent runs avoid unnecessary re-rendering.

Run once:

./mvlog_worker.py

Process one job:

./mvlog_worker.py --job romo2026

Force regeneration:

./mvlog_worker.py --job romo2026 --force

Pass extra movmaker options:

./mvlog_worker.py --movmaker-arg=--preview

Recommended scheduling is a systemd timer or cron with a separate lock, e.g. every MVLOG_WORKER_RECOMMENDED_INTERVAL_MINUTES minutes (5minutes; see mvlog_worker.py).

Usage

Basic:

python3 movmaker.py input

With options:

python3 movmaker.py input \
  --out-dir . \
  --image-duration 6 \
  --fade 3 \
  --audio-fade 10 \
  --audio-fade-in 2 \
  --video-fade 1.5 \
  --music-genre "cinematic punk rock" \
  --resolution 1920x1080 \
  --fps 30

Fast preview render:

python3 movmaker.py input --preview

Configuration knobs

The most common visual timing/styling tweaks are exposed as constants near the top of movmaker.py:

Constant Purpose Default
INTRO_LOGO_TARGET_HEIGHT_RATIO Portion of the video height used for the intro logo before padding (e.g. 0.75 = 75% of frame height). 0.75
INTRO_LOGO_EXTRA_HOLD Extra seconds the synthesized intro still remains before the first crossfade. 6.0
INTRO_LOGO_FADE_OFFSET Minimum time (s) the logo stays fully visible before the first xfade is allowed to start. 3.0
INTRO_LOGO_DEFAULT_ALPHA Default opacity for the intro logo (1 = fully opaque, 0 = invisible). 1.0
INTRO_TITLE_START_OFFSET Seconds after start before the title appears (or starts fading in). 4.0
INTRO_TITLE_FADE_LENGTH Seconds the fade-in/out lasts. 1.0
INTRO_TITLE_VISIBLE Seconds the title stays fully visible between fade in/out. 4.0
INTRO_TITLE_FADE_ENABLED If False, the title pops in at INTRO_TITLE_START_OFFSET instead of fading in. True
DEFAULT_IMAGE_DURATION Default fully visible time per still image before fades are added. 6.0
DEFAULT_CROSSFADE_DURATION Default crossfade duration between media items. 3.0
FIRST_IMAGE_DURATION_MULTIPLIER Multiplier applied to the first stills --image-duration to delay the first transition. 2.0
DEFAULT_AUDIO_FADE_IN Default audio fade-in duration. 2.0
DEFAULT_AUDIO_FADE_OUT Default audio fade-out duration. 10.0
DEFAULT_VIDEO_FADE Default video fade-in/out duration for the first/last real clips. 1.0
INTRO_VIDEO_FADE_ENABLED Whether the synthesized intro/logo pad gets the global fade-in effect (False keeps it fully visible from frame 0). False
CLIP_AUDIO_FADE Seconds used to fade video clip audio in/out while ducking the soundtrack. 0.75

Adjust these constants and rerun movmaker.py to change the intro behavior without touching the rest of the pipeline.

Current behavior

  • scans one flat input directory
  • supports an optional intro logo overlay via --intro-logo
  • supports AJAX save for input-dir create, edit, delete with toast notifications and redirect cleanup
  • auto-detects media/audio/data files by extension
  • shows each image fully visible for DEFAULT_IMAGE_DURATION seconds by default (6s), not counting crossfades
  • keeps the first image longer so the first transition starts after FIRST_IMAGE_DURATION_MULTIPLIER * DEFAULT_IMAGE_DURATION seconds (12s with defaults)
  • includes video clips inline
  • crossfades between media items over DEFAULT_CROSSFADE_DURATION seconds by default (3s)
  • loops/trims music to match the video
  • when a clip's "Use video file audio" option is enabled, movmaker crossfades (CLIP_AUDIO_FADE seconds) from the soundtrack to that clip's audio, ducks the music while it plays, then fades the music back in
  • if no audio file exists in the input folder, searches Jamendo using --music-genre and downloads one random Creative Commons track that allows non-commercial reuse and derivative works; default query is cinematic punk rock
  • reads the Jamendo client ID from JAMENDO_CLIENT_ID or ~/.config/movmaker/jamendo.env
  • fades music in over DEFAULT_AUDIO_FADE_IN seconds (2s) and out over DEFAULT_AUDIO_FADE_OUT seconds (10s) by default
  • builds a black intro still (matching the first media items resolution) when --intro-logo is provided, scales the logo to INTRO_LOGO_TARGET_HEIGHT_RATIO of the frame height (75% by default), shows it at INTRO_LOGO_DEFAULT_ALPHA opacity (1.0 = fully opaque) from frame 0, and keeps it on screen for audio_fade_in + INTRO_LOGO_EXTRA_HOLD seconds (with defaults this is DEFAULT_AUDIO_FADE_IN + INTRO_LOGO_EXTRA_HOLD, i.e. 8s) before allowing the first crossfade; the same clip is appended at the end so the last real scene crossfades back into the logo-on-black frame
  • opening title text shows up at INTRO_TITLE_START_OFFSET seconds (4s). When INTRO_TITLE_FADE_IN_ENABLED is True it fades in/out using INTRO_TITLE_FADE_LENGTH (1s); otherwise it appears instantly and only fades out (if INTRO_TITLE_FADE_LENGTH > 0). It remains fully visible for INTRO_TITLE_VISIBLE seconds (4s).
  • fades the first real clip in with the --video-fade duration (default DEFAULT_VIDEO_FADE, currently 1s) while leaving the logo intro instant-on when INTRO_VIDEO_FADE_ENABLED is False; when a logo intro is present, the reusable outro clip removes the need for a final global fade-out, so the crossfade into the outro handles the closing visuals while audio still follows DEFAULT_AUDIO_FADE_OUT
  • displays optional per-file captions at bottom center with slightly off-white text and a soft shadow; use | to split caption lines
  • adds persistent bottom-left title/date/place and bottom-right Bubulescu.Org stamps
  • preserves special characters in rendered overlays and metadata
  • uses safe ASCII only for generated filenames, so special letters become readable equivalents like Č -> C, å -> aa, ø -> o, ä -> ae, ß -> ss
  • embeds MP4 metadata: title, date, location/place, QuickTime location name, artist, comment, and creation_time
  • writes an MP4 file named like YYYYMMDD_title.mp4 in the current directory by default; use --out-dir DIR to choose another output directory
  • takes YYYYMMDD from the first picture/video metadata, falling back to file modification date
  • keeps console output minimal during rendering and writes detailed ffmpeg output to timestamped logs in .logs/ inside the input directory
  • uses local ffmpeg/ffmpeg and ffmpeg/ffprobe when present, otherwise system tools

Notes

This is an early version. The intended workflow is deliberately simple: dump files into one folder and run the script.

Web push notifications (MVLog web server)

Movmaker itself does not send web push notifications. The MVLog web UI handles "Show" notifications server-side: when an authenticated admin enables the "Show" checkbox for a job the web server attempts to send a single web-push to all subscribers and records the send. This keeps the VAPID private key on the web host and centralizes notification logic.

Files and behavior (deployed under the MVLog web root):

  • send_push.js — Node sender script (uses web-push). It reads subscriptions from stdin and accepts a push payload and a push config file path.
  • lib/send_push.php — server-side PHP helper used by new.php to call send_push.js and atomically update cache/push_notifications.json (the "shown" map).
  • api/enable_show.php — authenticated endpoint to toggle Show and trigger send (used by some integrations). It requires admin login.
  • new.php — the admin UI toggle now calls the server-side helper when Show is enabled (server-side send; non-fatal).
  • cache/push_subscriptions.json — stored subscriptions (array of subscription objects).
  • cache/push_notifications.json — recorded sends; the "shown" map prevents duplicate Show notifications.
  • push.json or /etc/mvlog/push.php — VAPID keys (private key must be kept secret and file permissions restricted).
  • log: /var/log/mvlog_notify.log — send attempts and errors are logged here.

Deployment checklist

  1. Install Node.js and the web-push package under the web root (or system-wide):

    npm install web-push --prefix /var/www/html/mvlog

  2. Place VAPID keys where the webserver can read them (recommended: /etc/mvlog/push.php or /var/www/html/mvlog/push.json) and set restrictive permissions (600 or 640, owner www-data). Do NOT commit private keys to git.

  3. Ensure /var/www/html/mvlog/cache exists and contains push_subscriptions.json and push_notifications.json (create {} if empty), owned by www-data and writable.

  4. Ensure /var/log/mvlog_notify.log is writable by the webserver user.

  5. The admin UI (new.php) requires login; enable Show from the admin UI to trigger a server-side send.

Security notes

  • api/enable_show.php requires admin login; do not expose it publicly without strong auth.
  • Protect push private keys: file permission 600 and owned by web user. Do not store private keys in repository.
  • Consider adding CSRF protection, rate limiting, and periodic pruning of expired subscriptions (HTTP 410 responses).

If you want deployment automation, alternative flows (worker-based sender or token-protected endpoint), or I should add a short admin checklist, tell me and I will update the docs.

Server-side push and Gemini generator (MVLog integration)

This repository includes light integration notes for the MVLog web server used in my local workflow. Two server-side helpers are deployed under the web root to keep sensitive keys on the server and to provide immediate, authenticated actions:

  • Server-side web-push (notifications)

    • The web UI triggers a server-side send when an authenticated admin enables the "Show" checkbox for a job. This keeps VAPID private keys on the webserver and avoids exposing them to worker nodes.
    • Key files and helper scripts (deployed under /var/www/html/mvlog):
      • lib/send_push.php — PHP helper that invokes the Node sender and atomically records send state.
      • send_push.js — Node script (uses the web-push package) to send individual pushes.
      • cache/push_subscriptions.json — stored subscriber list (array of subscription objects).
      • cache/push_notifications.json — recorded sends ("shown" map) to avoid duplicate notifications.
      • /etc/mvlog/push.php or /var/www/html/mvlog/push.json — VAPID key holder (private key must remain secret; set owner to root or www-data and permissions to 600/640).
    • Installation notes:
      • npm install web-push --prefix /var/www/html/mvlog
      • Ensure cache/ files exist and are writable by the webserver (www-data).
      • Keep logs in /var/log/mvlog_notify.log and grant the webserver write access.
    • Security:
      • api/enable_show.php requires admin login and the UI uses server-side calls; do not expose the endpoint without auth.
      • Consider CSRF protection, rate limiting, and periodic pruning of expired subscriptions (HTTP 410 responses).
  • Gemini-based description generator

    • Purpose: analyze an input directory (images/videos) and produce a short JSON description suitable for MVLog and movmaker metadata.

    • Script location: /var/www/html/mvlog/bin/generate_data.sh

    • What the script does:

      • Collects up to N representative frames from the input media (images produce one frame each; videos up to 3 frames depending on duration).
      • Builds a single multipart JSON request with the prompt and inline base64-encoded frames (the request is assembled with a small Python helper to avoid ARG_MAX issues).
      • Calls the Google Generative Language (Gemini) API via curl and saves the raw response text as /gemini_generated.json for review.
      • Appends a Description block to /data.txt only if data.txt does NOT already contain a Description key. It appends/creates only the Description block and does not modify any other keys or lines.
      • gemini_generated.json is retained as-is for manual inspection.
    • Output files:

      • /gemini_generated.json — raw JSON produced by Gemini (always written).
      • /data.txt — the script will append a "Description: |\n ..." block only if no Description exists yet (case-insensitive match). If data.txt is absent it will be created with the Description block only.
    • Configuration / environment:

      • GEMINI_API_KEY should be set in /etc/mvlog/gemini.env (recommended permissions: owner root:www-data, mode 640). The webserver runs the script as www-data so this file must be readable by www-data.
      • Required tools on the webserver: jq, python3, ffmpeg, ffprobe, curl.
    • Typical invocation (run as the webserver user so ownership/permissions behave correctly):

      sudo -u www-data /var/www/html/mvlog/bin/generate_data.sh /var/www/html/mvlog/in-dir/20230630_napoli "Naples" "Naples, Italy" 8

      Arguments are: ["Title"] ["Location"] [max_frames]

    • Error notes and troubleshooting:

      • If Gemini returns HTTP 429 RESOURCE_EXHAUSTED, check that GEMINI_API_KEY is tied to a project with billing and that the Generative Language API quotas are enabled.
      • The script uses a Python builder to avoid "Argument list too long" errors when embedding images; if you see jq errors referring to ARG_MAX, ensure you have the deployed version of generate_data.sh (the script in bin/ uses the Python builder).
      • Run the script as www-data to avoid permission problems reading /etc/mvlog/gemini.env and to create files owned by the webserver.

If you want this README split into a separate docs/ file or want me to add a short operational checklist for admins (quota checks, key install, testing steps), tell me and I will add it.

Automated Gemini description generator (generate_data.sh)

Location: /var/www/html/mvlog/bin/generate_data.sh

Purpose:

  • Produce a short human-style description (plus teaser/tags in the raw AI output) for an MVLog input job by sending sampled frames to Google's Gemini model.
  • Save the raw AI output to /gemini_generated.json (preserved for review).
  • If the job's data.txt does not already contain a Description: entry, append a Description block to data.txt and leave all other lines untouched. If data.txt doesn't exist the script will create it with the Description block only.
  • The script never overwrites an existing Description and does not modify gemini_generated.json.

Dependencies & configuration:

  • ffmpeg and ffprobe (for extracting frames)

  • jq (for small JSON extraction tasks)

  • python3 (used to build the request payload and avoid argument-list limits)

  • A valid GEMINI API key available in the environment as GEMINI_API_KEY. Recommended placement:

    • Create /etc/mvlog/gemini.env containing: export GEMINI_API_KEY='YOUR_KEY_HERE'
    • Set owner root:www-data and mode 640 so the webserver (www-data) can source it without exposing the key in git.

How the script works (high level):

  1. Collects image/video files from the input directory and extracts scaled JPEG frames (images → 1 frame each; videos → 13 frames depending on duration and remaining slots).

  2. Builds a generateContent request that embeds the frames as base64 "inline_data" parts and includes a short prompt describing the expected JSON format.

  3. Calls the Gemini REST API (model defaults to gemini-2.0-flash) and writes the AI response JSON to /gemini_generated.json.

  4. Extracts the "description" field from the AI JSON. If data.txt exists and already contains a Description: key (case-insensitive), the script does nothing to data.txt. Otherwise it appends a YAML-style block scalar to data.txt:

    Description: |

    If data.txt does not exist the script will create it containing only the Description: block.

Notes, running and troubleshooting:

  • Run as the webserver user so file ownership is correct:

    sudo -u www-data /var/www/html/mvlog/bin/generate_data.sh /var/www/html/mvlog/in-dir/20230630_napoli "Naples" "Naples, Italy" 8

  • If the GEMINI API key has no quota or billing is not enabled, the API will return 429 RESOURCE_EXHAUSTED and no description will be written. The raw gemini_generated.json will contain the error response.

  • Ensure /var/www/html/mvlog/in-dir/ is owned/writable by www-data; the script writes gemini_generated.json and may append to data.txt.

  • Install jq if missing: sudo apt install jq

  • The script intentionally preserves the full Gemini output (gemini_generated.json) so you can review and re-run manually if needed. If you prefer the old behavior (write a separate data_gemini.txt instead) or want additional fields appended (teaser/tags), say so and I will update the script and docs.