360 lines
16 KiB
Markdown
360 lines
16 KiB
Markdown
# 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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
export JAMENDO_CLIENT_ID='your_client_id_here'
|
||
```
|
||
|
||
or in a local config file:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```text
|
||
cinematic punk rock
|
||
```
|
||
|
||
Override it with:
|
||
|
||
```bash
|
||
python3 movmaker.py input --music-genre "upbeat rock"
|
||
```
|
||
|
||
## Input folder
|
||
|
||
Example:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```text
|
||
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
|
||
|
||
Run once:
|
||
|
||
```bash
|
||
./mvlog_worker.py
|
||
```
|
||
|
||
Process one job:
|
||
|
||
```bash
|
||
./mvlog_worker.py --job romo2026
|
||
```
|
||
|
||
Force regeneration:
|
||
|
||
```bash
|
||
./mvlog_worker.py --job romo2026 --force
|
||
```
|
||
|
||
Pass extra movmaker options:
|
||
|
||
```bash
|
||
./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 (5 minutes; see `mvlog_worker.py`).
|
||
|
||
## Usage
|
||
|
||
Basic:
|
||
|
||
```bash
|
||
python3 movmaker.py input
|
||
```
|
||
|
||
With options:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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 still’s `--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 (6 s), not counting crossfades
|
||
- keeps the first image longer so the first transition starts after `FIRST_IMAGE_DURATION_MULTIPLIER * DEFAULT_IMAGE_DURATION` seconds (12 s with defaults)
|
||
- includes video clips inline
|
||
- crossfades between media items over `DEFAULT_CROSSFADE_DURATION` seconds by default (3 s)
|
||
- 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 (2 s) and out over `DEFAULT_AUDIO_FADE_OUT` seconds (10 s) by default
|
||
- builds a black intro still (matching the first media item’s 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. 8 s) 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 (4 s). When `INTRO_TITLE_FADE_IN_ENABLED` is `True` it fades in/out using `INTRO_TITLE_FADE_LENGTH` (1 s); otherwise it appears instantly and only fades out (if `INTRO_TITLE_FADE_LENGTH` > 0). It remains fully visible for `INTRO_TITLE_VISIBLE` seconds (4 s).
|
||
- fades the first real clip in with the `--video-fade` duration (default `DEFAULT_VIDEO_FADE`, currently 1 s) 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.
|
||
|
||
## 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 <inputdir>/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 → 1–3 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 <inputdir>/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: |
|
||
<two-space indented description text>
|
||
|
||
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/<job> 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.
|
||
|
||
|