External helper for Immich that detects stereoscopic wiggle sequences (burst-like near-duplicate frames) and exports animated GIF wigglegrams back into your library.
- Index — downloads Immich thumbnails and stores perceptual hashes (
phash) in SQLite. - Detect — builds time-window graph clusters from perceptual hashes (not just consecutive pairs), then validates candidates
- Export — builds a boomerang GIF from originals, uploads it to Immich, and adds it to a
Wigglegramsalbum.
Detection defaults to dry-run so you can tune the threshold before uploading anything.
- Python 3.11+ (or uv for the recommended install path)
- A running Immich instance (v2 or v3)
- API key with permissions:
asset.read,asset.view,asset.download,asset.upload,album.read,album.create,albumAsset.create
./scripts/setup.sh
uv run image-helper doctor
uv run image-helper index
uv run image-helper detectsetup.sh runs uv sync, creates .env from .env.example when needed, and prints next steps. For dev dependencies (pytest):
DEV=1 ./scripts/setup.shuv sync
uv run image-helper config init
uv run image-helper doctorpip fallback (no uv):
python -m venv .venv && source .venv/bin/activate
pip install -e .
cp .env.example .env # edit IMMICH_URL and IMMICH_API_KEY
image-helper doctor# Index all images (incremental by checksum)
image-helper index
# Detect wiggle groups (dry-run, default)
image-helper detect
# Export GIFs to Immich (explicit upload)
image-helper detect --upload
# or
image-helper export
# Poll for newly updated assets
image-helper daemon --once
image-helper daemonSettings are loaded with this precedence:
- Shell environment variables — always override file values
- Env file — first existing file wins:
--env-file→IMAGE_HELPER_ENV_FILE→./.env→~/.config/image-helper/env - Built-in defaults
Inspect effective values at any time:
image-helper config showInitialize or reset your env file:
image-helper config init
image-helper config init --output ~/.config/image-helper/env| Variable | Default | Description |
|---|---|---|
IMMICH_URL |
http://localhost:2283/api |
Immich API base URL |
IMMICH_API_KEY |
— | API key (required) |
HASH_DB_PATH |
./data/hashes.sqlite3 |
SQLite hash cache |
WIGGLE_THRESHOLD |
10 |
Max phash distance for grouping |
WIGGLE_MIN_DISTANCE |
2 |
Reject edit-like groups below this average adjacent distance |
WIGGLE_TIME_WINDOW_SECONDS |
3.0 |
Max seconds between adjacent frames |
WIGGLE_FRAME_DURATION_MS |
100 |
GIF frame duration |
WIGGLE_MAX_SIZE |
600 |
Max GIF frame dimension |
WIGGLE_BOOMERANG |
true |
Reverse playback in GIF |
WIGGLE_FRAME_FIT |
letterbox |
Frame alignment mode (letterbox or crop) |
WIGGLE_HASH_SOURCE |
original |
Hash originals (original) or thumbnails (thumbnail) |
WIGGLE_MAX_DIMENSION_DRIFT |
0.02 |
Reject groups when width/height drift exceeds this ratio |
WIGGLE_EXCLUDE_STACKED |
true |
Reject groups whose members share an Immich stack |
WIGGLE_MIN_FRAMES |
2 |
Minimum frames required in a group |
WIGGLE_NEIGHBOR_SEARCH_PRIMARY_ONLY |
false |
Webhook neighbor search: exclude non-primary stacked assets |
WIGGLE_MAX_GAP_FRAMES |
0 |
Allow up to N non-matching frames between wiggle frames |
WIGGLE_SETTLE_SECONDS |
0 |
Wait after last detection before export (burst upload debounce; 0 = off) |
WIGGLE_REQUIRE_BURST_METADATA |
false |
Require matching EXIF burst UUID on every frame |
WIGGLE_STACK_WITH_SOURCES |
false |
Stack exported GIF with source frames in Immich |
WIGGLE_STABILIZE |
true |
Enable frame stabilization during GIF export |
WIGGLE_STABILIZE_MODE |
auto |
Stabilization mode (auto, rigid, translate, horizontal, off) |
WIGGLE_STABILIZE_REFERENCE |
middle |
Reference frame for alignment (first or middle) |
WIGGLE_STABILIZE_CROP |
true |
Crop to shared overlap after alignment |
WIGGLE_STABILIZE_MAX_ROTATION_DEG |
3.0 |
Reject alignment estimates above this rotation |
WIGGLE_STABILIZE_WORKING_MAX_EDGE |
1024 |
Resolution used for transform estimation |
INDEX_WORKERS |
4 |
Parallel workers for index hashing (1 = sequential) |
WIGGLE_ALBUM_NAME |
Wigglegrams |
Target album for exports |
DAEMON_POLL_INTERVAL_SECONDS |
60 |
Daemon poll interval |
WEBHOOK_HOST |
0.0.0.0 |
Webhook bind address |
WEBHOOK_PORT |
8765 |
Webhook port |
WEBHOOK_SECRET |
— | Optional webhook auth header |
DEVICE_ID |
image-helper |
Upload device id |
Verify connectivity before indexing:
image-helper doctorRun the daemon alongside an existing Immich Docker Compose stack:
cp docker/.env.example docker/.env
# Edit IMMICH_URL and IMMICH_API_KEYSame Docker network as Immich (most common):
IMMICH_URL=http://immich-server:2283/api
IMMICH_DOCKER_NETWORK=immich_defaultFind your Immich network name with docker network ls (often <project>_default).
Immich on the Docker host (helper in a container, Immich on localhost):
IMMICH_URL=http://host.docker.internal:2283/apiOn Linux you may need extra_hosts: ["host.docker.internal:host-gateway"] on the service.
Start the sidecar:
docker compose -f docker/compose.yml --project-directory docker up -d --buildHash data persists in the helper-data volume.
For the Docker sidecar, docker/.env.example recommends WIGGLE_HASH_SOURCE=thumbnail (faster indexing; originals are still downloaded when building GIFs), WIGGLE_SETTLE_SECONDS=15 (wait for burst uploads to finish), and WIGGLE_STACK_WITH_SOURCES=true (stack the GIF with source frames).
- Run
image-helper indexon your library. - Run
image-helper detect --show-rejectedand inspect accepted groups plus rejection reasons. - Preview a candidate locally without uploading:
image-helper detect preview -o /tmp/preview.gif --group-index 0 - Lower
WIGGLE_THRESHOLDfor stricter matching; raise it if bursts are missed. - Raise
WIGGLE_MIN_DISTANCEif Immich edits of a single photo still group together. - Lower
WIGGLE_MAX_DIMENSION_DRIFTif unstacked crop edits slip through. - Adjust
WIGGLE_TIME_WINDOW_SECONDSif unrelated photos get grouped. - Raise
WIGGLE_MAX_GAP_FRAMESif an unrelated frame splits a burst in time order.
Stabilization runs during GIF export (enabled by default). Preview a group locally to compare modes:
WIGGLE_STABILIZE_MODE=off image-helper detect preview -o /tmp/raw.gif --group-index 0
WIGGLE_STABILIZE_MODE=auto image-helper detect preview -o /tmp/stable.gif --group-index 0| Mode | Behavior |
|---|---|
auto (default) |
Corrects vertical shake + rotation, anchors median depth plane, preserves horizontal parallax |
rigid |
Full similarity alignment to the reference frame |
translate |
Translation-only alignment |
horizontal |
Horizontal registration only |
off |
No stabilization (legacy behavior) |
If exports crop too aggressively, set WIGGLE_STABILIZE_CROP=false. If the 3D wiggle looks flattened, try WIGGLE_STABILIZE_MODE=translate or horizontal.
After upgrading image-helper with new metadata fields, re-index once:
image-helper index --forceRejected candidates are filtered for:
- shared Immich stacks (edited variants stacked with the original)
- mismatched dimensions (typical of crop edits)
- edit-like near-duplicates with identical/non-progressive timestamps
- previously exported
wiggle_*.gifassets
# From repository root (recommended)
uv sync --group dev
uv run pytest
# Or use the helper script from any directory
./scripts/test.shThese cover detector logic, configuration, workflow payload parsing, and webhook handler behavior without Immich.
If you previously ran E2E from docker/, note that Docker may leave a root-owned docker/test-data/ directory. Run pytest from the repo root (or ./scripts/test.sh) — not from docker/.
This spins up an isolated Immich stack, uploads synthetic wiggle burst frames, runs the full index → detect → export pipeline, and verifies the GIF lands in the Wigglegrams album.
Requirements: Docker with WSL2 integration enabled (Docker Desktop → Settings → Resources → WSL integration).
# One command — brings stack up, runs pipeline, tears down
./scripts/run-e2e.sh
# Keep Immich running after success for manual inspection
KEEP_STACK=1 ./scripts/run-e2e.shWhat it does:
scripts/generate_fixtures.py— creates 3 slightly-shifted PNG burst frames + 1 control imagedocker/compose.test.yml— Immich (server, postgres, redis, ML) on port2283scripts/e2e_bootstrap.py— creates admin, API key, uploads fixtures, waits for thumbnailsimage-helpercontainer — runs index/detect/export againsthttp://immich-server:2283/apiscripts/e2e_verify.py— asserts GIF exists in theWigglegramsalbum
Exercises the webhook receiver and wigglegram export path against Immich preview builds. When Immich ships a webhook workflow step, the test installs a real AssetCreate workflow; until then it simulates webhook POSTs after upload.
# Preview Immich (default: v3-rc) + image-helper webhook daemon
./scripts/run-e2e-workflow.sh
# Pin a specific release candidate
IMMICH_WORKFLOW_VERSION=v3.0.0-rc.2 ./scripts/run-e2e-workflow.sh
# Debug workflow method discovery
WORKFLOW_DEBUG=1 ./scripts/run-e2e-workflow.shWhat it does:
- Starts Immich with
IMMICH_WORKFLOW_VERSION(defaultv3-rc) - Starts
image-helper-webhook(docker/Dockerfile.webhook) on port8765 scripts/e2e_workflow_bootstrap.py— installs workflow when a webhook step exists, otherwise simulatesAssetCreatewebhooks after upload- Waits for async export and verifies with
scripts/e2e_verify.py
Immich v3-rc exposes /workflows and /plugins/methods, but the core plugin does not yet include an outbound HTTP webhook action. image-helper doctor --workflows reports whether a webhook step is available on your instance.
Manual steps (if you prefer step-by-step):
python scripts/generate_fixtures.py testdata/fixtures
docker compose --env-file docker/immich.env \
-f docker/compose.test.yml --project-directory docker up -d --wait
python scripts/e2e_bootstrap.py --fixtures-dir testdata/fixtures
docker compose --env-file docker/immich.env \
-f docker/compose.test.yml --project-directory docker \
--profile helper run --rm image-helper index
docker compose --env-file docker/immich.env \
-f docker/compose.test.yml --project-directory docker \
--profile helper run --rm image-helper export
# Immich UI: http://localhost:2283 (admin@image-helper.test / image-helper-test)Test data is stored under docker/test-data/ and removed on teardown (docker compose down -v).
Run the webhook receiver and install a workflow that POSTs on AssetCreate:
uv sync --extra webhook
image-helper webhookInstall the workflow via API (requires admin credentials once):
export IMMICH_ADMIN_EMAIL=you@example.com
export IMMICH_ADMIN_PASSWORD=...
image-helper immich install-workflow \
--webhook-url http://<helper-host>:8765/webhook/immichSet WEBHOOK_SECRET in your env and configure the same value in the workflow step header x-immich-webhook-secret.
Check workflow support on your Immich build:
image-helper doctor --workflowsThe receiver accepts JSON payloads with an asset object. Supported shapes:
{ "trigger": "AssetCreate", "asset": { "id": "..." } }{ "asset": { "id": "..." } }{ "type": "AssetCreationEvent", "asset": { "id": "..." } }
If localDateTime is missing, image-helper fetches the asset from Immich and waits for thumbnails before indexing.
Immich (REST API) <-> image-helper <-> SQLite hash store
|
GIF upload + album
Heavy image work stays in this helper; Immich workflows can trigger it via webhook.
GPL-3.0-or-later (algorithm ported from wiggle-wiggle).