From e4cadf08dc92b329fcf0816a94834b30f23f35db Mon Sep 17 00:00:00 2001 From: alexz Date: Tue, 26 May 2026 08:43:44 -0700 Subject: [PATCH] Add mergedrop: drag-and-drop video merger (ffmpeg + FastAPI + React) --- apps/mergedrop/build/README.md | 28 +++++++++++++++ apps/mergedrop/build/build.sh | 37 ++++++++++++++++++++ apps/mergedrop/config.json | 43 +++++++++++++++++++++++ apps/mergedrop/docker-compose.json | 47 ++++++++++++++++++++++++++ apps/mergedrop/metadata/description.md | 46 +++++++++++++++++++++++++ 5 files changed, 201 insertions(+) create mode 100644 apps/mergedrop/build/README.md create mode 100755 apps/mergedrop/build/build.sh create mode 100644 apps/mergedrop/config.json create mode 100644 apps/mergedrop/docker-compose.json create mode 100644 apps/mergedrop/metadata/description.md diff --git a/apps/mergedrop/build/README.md b/apps/mergedrop/build/README.md new file mode 100644 index 0000000..8eb5686 --- /dev/null +++ b/apps/mergedrop/build/README.md @@ -0,0 +1,28 @@ +# MergeDrop image build + +The Dockerfile and full source for the **MergeDrop** image live in a separate Gitea repo: + +- Source: +- Published image: `git.alexzaw.dev/alexz/mergedrop:` + +This folder holds only a thin helper script (`build.sh`) that clones the source +repo and pushes the resulting image to the in-house Docker registry. + +## Rebuild + +```bash +cd build/ +./build.sh # builds + pushes :latest and the version tag +IMAGE_TAG=1.0.1 ./build.sh # override the tag +``` + +Requires `docker login git.alexzaw.dev` to already be done (or `~/.docker/config.json` +to have the registry credentials, which is the case on this host). + +## Notes + +- The image is a two-stage build (Node 20 → Python 3.11-slim) and produces an + `amd64` image only. Add `--platform linux/arm64` to `build.sh` args to cross-build. +- The application is a single FastAPI process that serves the static React build + and a `/api/*` JSON API on the same port (`8000` inside the container). +- `ffmpeg` is installed in the runtime image; no host-level ffmpeg required. diff --git a/apps/mergedrop/build/build.sh b/apps/mergedrop/build/build.sh new file mode 100755 index 0000000..21d6483 --- /dev/null +++ b/apps/mergedrop/build/build.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Build and push the MergeDrop Docker image. +# Clones the source repo (or reuses an existing checkout) and tags/pushes +# both :latest and the version tag. + +set -euo pipefail + +IMAGE_NAME="${IMAGE_NAME:-git.alexzaw.dev/alexz/mergedrop}" +IMAGE_TAG="${IMAGE_TAG:-1.0.0}" +SOURCE_REPO="${SOURCE_REPO:-https://git.alexzaw.dev/alexz/video-merger.git}" +WORKDIR="${WORKDIR:-/tmp/mergedrop-build}" + +echo "==> Preparing source at ${WORKDIR}" +if [[ -d "${WORKDIR}/.git" ]]; then + git -C "${WORKDIR}" fetch --quiet origin + git -C "${WORKDIR}" reset --hard --quiet origin/HEAD +else + rm -rf "${WORKDIR}" + git clone --quiet "${SOURCE_REPO}" "${WORKDIR}" +fi + +echo "==> Building ${IMAGE_NAME}:${IMAGE_TAG} (and :latest)" +cd "${WORKDIR}" +docker build "$@" \ + -t "${IMAGE_NAME}:${IMAGE_TAG}" \ + -t "${IMAGE_NAME}:latest" \ + . + +echo "==> Pushing ${IMAGE_NAME}:${IMAGE_TAG}" +docker push "${IMAGE_NAME}:${IMAGE_TAG}" +echo "==> Pushing ${IMAGE_NAME}:latest" +docker push "${IMAGE_NAME}:latest" + +echo "" +echo "Done. To install on Runtipi:" +echo " sudo /etc/runtipi/runtipi-cli appstore update" +echo " sudo /etc/runtipi/runtipi-cli app install mergedrop:runtipi" diff --git a/apps/mergedrop/config.json b/apps/mergedrop/config.json new file mode 100644 index 0000000..06834c2 --- /dev/null +++ b/apps/mergedrop/config.json @@ -0,0 +1,43 @@ +{ + "name": "MergeDrop", + "id": "mergedrop", + "available": true, + "short_desc": "Drag-and-drop video merger — drop clips, reorder, build one MP4.", + "author": "alexz", + "port": 8765, + "categories": ["media", "utilities"], + "description": "Self-hosted, drag-and-drop video merger. Upload multiple clips, reorder them, and produce a single normalized MP4 (h.264 + AAC) via ffmpeg. Handles mixed codecs, resolutions and framerates. Live progress streaming, in-browser preview, and downloadable result. No accounts, no telemetry, single container.", + "tipi_version": 1, + "version": "1.0.0", + "source": "https://git.alexzaw.dev/alexz/video-merger", + "website": "https://git.alexzaw.dev/alexz/video-merger", + "exposable": true, + "dynamic_config": true, + "no_gui": false, + "form_fields": [ + { + "type": "text", + "label": "Max files per merge", + "default": "20", + "required": false, + "env_variable": "MERGEDROP_MAX_FILES" + }, + { + "type": "text", + "label": "Max per-file size (bytes)", + "default": "2147483648", + "required": false, + "env_variable": "MERGEDROP_MAX_FILE_SIZE", + "hint": "Default is 2 GiB (2147483648). Raise for larger source clips." + }, + { + "type": "text", + "label": "Output retention (hours)", + "default": "24", + "required": false, + "env_variable": "MERGEDROP_OUTPUT_MAX_AGE_HOURS", + "hint": "How long merged MP4s stay on disk before the janitor deletes them." + } + ], + "supported_architectures": ["amd64"] +} diff --git a/apps/mergedrop/docker-compose.json b/apps/mergedrop/docker-compose.json new file mode 100644 index 0000000..b8f87d4 --- /dev/null +++ b/apps/mergedrop/docker-compose.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://schemas.runtipi.io/dynamic-compose.json", + "schemaVersion": 2, + "services": [ + { + "name": "mergedrop", + "image": "git.alexzaw.dev/alexz/mergedrop:1.0.0", + "isMain": true, + "internalPort": 8000, + "environment": [ + { "key": "PORT", "value": "8000" }, + { "key": "TZ", "value": "${TZ}" }, + { "key": "MERGEDROP_UPLOAD_DIR", "value": "/data/uploads" }, + { "key": "MERGEDROP_OUTPUT_DIR", "value": "/data/outputs" }, + { "key": "MERGEDROP_THUMB_DIR", "value": "/data/thumbs" }, + { "key": "MERGEDROP_MAX_FILES", "value": "${MERGEDROP_MAX_FILES}" }, + { "key": "MERGEDROP_MAX_FILE_SIZE", "value": "${MERGEDROP_MAX_FILE_SIZE}" }, + { "key": "MERGEDROP_OUTPUT_MAX_AGE_HOURS", "value": "${MERGEDROP_OUTPUT_MAX_AGE_HOURS}" }, + { "key": "CORS_ORIGINS", "value": "*" } + ], + "volumes": [ + { + "hostPath": "${APP_DATA_DIR}/data/uploads", + "containerPath": "/data/uploads", + "readOnly": false + }, + { + "hostPath": "${APP_DATA_DIR}/data/outputs", + "containerPath": "/data/outputs", + "readOnly": false + }, + { + "hostPath": "${APP_DATA_DIR}/data/thumbs", + "containerPath": "/data/thumbs", + "readOnly": false + } + ], + "healthCheck": { + "test": "curl -fsS http://127.0.0.1:8000/api/ || exit 1", + "interval": "30s", + "timeout": "5s", + "retries": 3, + "startPeriod": "20s" + } + } + ] +} diff --git a/apps/mergedrop/metadata/description.md b/apps/mergedrop/metadata/description.md new file mode 100644 index 0000000..8c5d935 --- /dev/null +++ b/apps/mergedrop/metadata/description.md @@ -0,0 +1,46 @@ +# MergeDrop + +A self-hosted, drag-and-drop **video merger**. Drop clips → reorder → click **Merge & Build MP4** → download a single ready-to-share file. + +## Why this exists + +Most "merge video" tools are bloated desktop apps, paid SaaS, or sketchy converter sites that re-upload your footage to who-knows-where. MergeDrop is the opposite: + +- 100% self-hosted, runs in a single container on your Runtipi +- No accounts, no telemetry, no third-party upload caps +- Mixed formats / resolutions / framerates → normalized to a clean **1080p30 h.264 + AAC** MP4 + +## Features + +- Drag-and-drop upload, drag-to-reorder, drag-to-remove +- Live ffmpeg progress (percentage + tail of stderr) per merge job +- In-browser preview of the merged result, then one-click download +- Built-in janitor — old outputs are auto-deleted after a configurable retention window + +## Supported inputs + +`mp4`, `mov`, `mkv`, `avi`, `webm`, `m4v`, `flv`, `wmv`, `mpg/mpeg` (mixed codecs and dimensions are normalized via two-pass ffmpeg pipeline). + +## Output presets + +`1080p` (default), `720p`, `4K`, `vertical 9:16`, `square 1:1` — selectable per job from the UI. + +## Configuration + +| Setting | Default | Description | +| --- | --- | --- | +| Max files per merge | `20` | Hard cap per job | +| Max per-file size | `2 GiB` | Per-upload limit (bytes) | +| Output retention | `24h` | Janitor sweeps merged MP4s older than this | + +## Volumes (persistent) + +- `${APP_DATA_DIR}/data/uploads` — staged source clips +- `${APP_DATA_DIR}/data/outputs` — merged MP4 results +- `${APP_DATA_DIR}/data/thumbs` — generated upload thumbnails + +## Tech stack + +- Backend: FastAPI + `ffmpeg` subprocess (async, streamed progress) +- Frontend: React 19 + Tailwind + `@dnd-kit` + `react-dropzone` +- Single container, single port, no database