Migrate MVLog worker to webserver-local deployment
This commit is contained in:
parent
8fc2e3b4c9
commit
85c4333e77
6 changed files with 126 additions and 19 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -6,8 +6,10 @@ venv/
|
||||||
|
|
||||||
# Generated videos / outputs
|
# Generated videos / outputs
|
||||||
.logs/
|
.logs/
|
||||||
|
*.log
|
||||||
movmaker.log
|
movmaker.log
|
||||||
out/
|
out/
|
||||||
|
output/
|
||||||
*.mp4
|
*.mp4
|
||||||
*.mov
|
*.mov
|
||||||
*.mkv
|
*.mkv
|
||||||
|
|
@ -19,9 +21,12 @@ input*/
|
||||||
media/
|
media/
|
||||||
ffmpeg/
|
ffmpeg/
|
||||||
node_modules/
|
node_modules/
|
||||||
img/
|
|
||||||
aabenraa/
|
aabenraa/
|
||||||
|
|
||||||
|
# Bundled/static assets: track img/moto_travel.png, ignore generated/downloaded extras
|
||||||
|
img/.logs/
|
||||||
|
img/*.mp3
|
||||||
|
|
||||||
# Assistant/project-local context
|
# Assistant/project-local context
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
|
|
||||||
|
|
|
||||||
43
README.md
43
README.md
|
|
@ -6,7 +6,7 @@ Flat-folder Python + ffmpeg movie maker, plus optional MVLog worker.
|
||||||
|
|
||||||
- Input: one directory with mixed images/videos/audio + optional `data.txt`
|
- Input: one directory with mixed images/videos/audio + optional `data.txt`
|
||||||
- Output: one rendered MP4 with transitions, captions, intro/logo, stamps, and embedded metadata
|
- Output: one rendered MP4 with transitions, captions, intro/logo, stamps, and embedded metadata
|
||||||
- Optional worker (`mvlog_worker.py`) syncs/render/publishes MVLog jobs to a remote webserver
|
- Optional worker (`mvlog_worker.py`) renders/publishes MVLog jobs either locally on the webserver or via the older SSH/SCP remote mode
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -96,7 +96,7 @@ clip01.mp4 audio: yes
|
||||||
|
|
||||||
## MVLog worker (`mvlog_worker.py`)
|
## MVLog worker (`mvlog_worker.py`)
|
||||||
|
|
||||||
Worker is for MVLog web integration (remote `in-dir` -> local render -> remote `out-dir`).
|
Worker is for MVLog web integration. Preferred deployment is webserver-local: `/var/www/html/in-dir` -> render on the webserver -> `/var/www/html/out-dir`. Older SSH/SCP remote mode is still available for rollback/manual use.
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
|
|
||||||
|
|
@ -107,18 +107,24 @@ Key points:
|
||||||
- Publishes output atomically and clears remote cache file
|
- Publishes output atomically and clears remote cache file
|
||||||
- Supports remote mode (SSH/SCP) and `--local` mode
|
- Supports remote mode (SSH/SCP) and `--local` mode
|
||||||
|
|
||||||
Run once:
|
Run once on the webserver:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvlog_worker.py --local --remote-root /var/www/html --work-dir /srv/mvlog/work
|
||||||
|
```
|
||||||
|
|
||||||
|
Single job on the webserver:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./mvlog_worker.py --local --remote-root /var/www/html --work-dir /srv/mvlog/work --job 20260525_mohnesee
|
||||||
|
```
|
||||||
|
|
||||||
|
Older SSH/SCP remote mode from another machine:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./mvlog_worker.py
|
./mvlog_worker.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Single job:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./mvlog_worker.py --job 20260525_mohnesee
|
|
||||||
```
|
|
||||||
|
|
||||||
Force rerender:
|
Force rerender:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -131,6 +137,25 @@ Pass extra movmaker args:
|
||||||
./mvlog_worker.py --movmaker-arg=--preview
|
./mvlog_worker.py --movmaker-arg=--preview
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Webserver deployment
|
||||||
|
|
||||||
|
Recommended deployment keeps this repo checked out on the webserver at:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/srv/mvlog/app
|
||||||
|
```
|
||||||
|
|
||||||
|
The worker writes temporary render data outside git at `/srv/mvlog/work` and publishes public files to `/var/www/html/out-dir`.
|
||||||
|
|
||||||
|
Systemd templates are in `deploy/systemd/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp deploy/systemd/mvlog-worker.service /etc/systemd/system/
|
||||||
|
sudo cp deploy/systemd/mvlog-worker.timer /etc/systemd/system/
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now mvlog-worker.timer
|
||||||
|
```
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
- This repo does **not** send web push itself; push is handled by MVLog web app server-side.
|
- This repo does **not** send web push itself; push is handled by MVLog web app server-side.
|
||||||
|
|
|
||||||
13
deploy/systemd/mvlog-worker.service
Normal file
13
deploy/systemd/mvlog-worker.service
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
[Unit]
|
||||||
|
Description=MVLog movmaker worker (webserver local)
|
||||||
|
Wants=network-online.target
|
||||||
|
After=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
WorkingDirectory=/srv/mvlog/app
|
||||||
|
ExecStart=/srv/mvlog/app/mvlog_worker.py --local --remote-root /var/www/html --work-dir /srv/mvlog/work
|
||||||
|
User=hbrain
|
||||||
|
Group=www-data
|
||||||
|
UMask=0002
|
||||||
|
TimeoutStartSec=12h
|
||||||
11
deploy/systemd/mvlog-worker.timer
Normal file
11
deploy/systemd/mvlog-worker.timer
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[Unit]
|
||||||
|
Description=Run MVLog movmaker worker periodically on webserver
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=2min
|
||||||
|
OnUnitInactiveSec=5min
|
||||||
|
AccuracySec=30s
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
BIN
img/moto_travel.png
Normal file
BIN
img/moto_travel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
|
|
@ -51,8 +51,13 @@ def remote_q(path: str) -> str:
|
||||||
return quote(path)
|
return quote(path)
|
||||||
|
|
||||||
|
|
||||||
def list_jobs(host: str, remote_root: str) -> list[str]:
|
def list_jobs(host: str | None, remote_root: str) -> list[str]:
|
||||||
cmd = f"cd {remote_q(remote_root)} && find in-dir -mindepth 1 -maxdepth 1 -type d -printf '%f\\n' | sort"
|
if host is None:
|
||||||
|
root = Path(remote_root) / "in-dir"
|
||||||
|
if not root.is_dir():
|
||||||
|
return []
|
||||||
|
return sorted(path.name for path in root.iterdir() if path.is_dir() and not path.name.startswith('.'))
|
||||||
|
cmd = f"cd {remote_q(remote_root)} && find in-dir -mindepth 1 -maxdepth 1 -type d ! -name '.*' -printf '%f\\n' | sort"
|
||||||
out = ssh(host, cmd).stdout
|
out = ssh(host, cmd).stdout
|
||||||
return [line.strip() for line in out.splitlines() if line.strip()]
|
return [line.strip() for line in out.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
|
@ -80,17 +85,41 @@ def read_article_id(host: str | None, job_dir: str) -> str | None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def acquire_lock(host: str, job_dir: str) -> bool:
|
def acquire_lock(host: str | None, job_dir: str) -> bool:
|
||||||
lock = f"{job_dir}/{LOCK_NAME}"
|
lock = f"{job_dir}/{LOCK_NAME}"
|
||||||
|
if host is None:
|
||||||
|
try:
|
||||||
|
Path(lock).mkdir()
|
||||||
|
Path(lock, "started_at").write_text(now() + "\n", encoding="utf-8")
|
||||||
|
return True
|
||||||
|
except FileExistsError:
|
||||||
|
return False
|
||||||
cmd = f"mkdir {remote_q(lock)} 2>/dev/null && date -Iseconds > {remote_q(lock + '/started_at')}"
|
cmd = f"mkdir {remote_q(lock)} 2>/dev/null && date -Iseconds > {remote_q(lock + '/started_at')}"
|
||||||
return ssh(host, cmd, check=False).returncode == 0
|
return ssh(host, cmd, check=False).returncode == 0
|
||||||
|
|
||||||
|
|
||||||
def release_lock(host: str, job_dir: str) -> None:
|
def release_lock(host: str | None, job_dir: str) -> None:
|
||||||
|
if host is None:
|
||||||
|
shutil.rmtree(Path(job_dir) / LOCK_NAME, ignore_errors=True)
|
||||||
|
return
|
||||||
ssh(host, f"rm -rf {remote_q(job_dir + '/' + LOCK_NAME)}", check=False)
|
ssh(host, f"rm -rf {remote_q(job_dir + '/' + LOCK_NAME)}", check=False)
|
||||||
|
|
||||||
|
|
||||||
def remote_fingerprint(host: str, job_dir: str) -> str:
|
def remote_fingerprint(host: str | None, job_dir: str) -> str:
|
||||||
|
if host is None:
|
||||||
|
entries = []
|
||||||
|
root = Path(job_dir)
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
|
dirnames[:] = [d for d in dirnames if d != LOCK_NAME]
|
||||||
|
for name in filenames:
|
||||||
|
if name in {STATE_NAME, ENABLED_NAME, PREVIEW_NAME, HIDDEN_NAME, PERMALINK_NAME, ARTICLE_ID_NAME, '.mvlog-hide-map'}:
|
||||||
|
continue
|
||||||
|
path = Path(dirpath) / name
|
||||||
|
rel = path.relative_to(root).as_posix()
|
||||||
|
st = path.stat()
|
||||||
|
entries.append([rel, st.st_size, st.st_mtime_ns])
|
||||||
|
entries.sort()
|
||||||
|
return hashlib.sha256(json.dumps(entries, ensure_ascii=False, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||||
script = r'''
|
script = r'''
|
||||||
import hashlib, json, os, sys
|
import hashlib, json, os, sys
|
||||||
root = sys.argv[1]
|
root = sys.argv[1]
|
||||||
|
|
@ -169,15 +198,28 @@ def write_state(host: str | None, job_dir: str, state: dict) -> None:
|
||||||
ssh(host, cmd, input_text=data)
|
ssh(host, cmd, input_text=data)
|
||||||
|
|
||||||
|
|
||||||
def copy_job_from_remote(host: str, job_dir: str, local_input: Path) -> None:
|
def copy_job_from_remote(host: str | None, job_dir: str, local_input: Path) -> None:
|
||||||
local_input.mkdir(parents=True, exist_ok=True)
|
local_input.mkdir(parents=True, exist_ok=True)
|
||||||
|
excludes = {LOCK_NAME, STATE_NAME, PERMALINK_NAME}
|
||||||
|
if host is None:
|
||||||
|
src = Path(job_dir)
|
||||||
|
for path in src.rglob("*"):
|
||||||
|
if any(part in excludes for part in path.relative_to(src).parts):
|
||||||
|
continue
|
||||||
|
dest = local_input / path.relative_to(src)
|
||||||
|
if path.is_dir():
|
||||||
|
dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
elif path.is_file():
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy2(path, dest)
|
||||||
|
return
|
||||||
remote_cmd = (
|
remote_cmd = (
|
||||||
f"tar --exclude={quote(LOCK_NAME)} --exclude={quote(STATE_NAME)} --exclude={quote(PERMALINK_NAME)} "
|
f"tar --exclude={quote(LOCK_NAME)} --exclude={quote(STATE_NAME)} --exclude={quote(PERMALINK_NAME)} "
|
||||||
f"-C {remote_q(job_dir)} -cf - ."
|
f"-C {remote_q(job_dir)} -cf - ."
|
||||||
)
|
)
|
||||||
p1 = subprocess.Popen(["ssh", host, remote_cmd], stdout=subprocess.PIPE)
|
p1 = subprocess.Popen(["ssh", host, remote_cmd], stdout=subprocess.PIPE)
|
||||||
try:
|
try:
|
||||||
p2 = subprocess.run(["tar", "-xf", "-", "-C", str(local_input)], stdin=p1.stdout, check=True)
|
subprocess.run(["tar", "-xf", "-", "-C", str(local_input)], stdin=p1.stdout, check=True)
|
||||||
assert p1.stdout is not None
|
assert p1.stdout is not None
|
||||||
p1.stdout.close()
|
p1.stdout.close()
|
||||||
rc = p1.wait()
|
rc = p1.wait()
|
||||||
|
|
@ -190,8 +232,19 @@ def copy_job_from_remote(host: str, job_dir: str, local_input: Path) -> None:
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def publish_output(host: str, remote_root: str, local_mp4: Path, old_output: str | None) -> str:
|
def publish_output(host: str | None, remote_root: str, local_mp4: Path, old_output: str | None) -> str:
|
||||||
out_name = local_mp4.name
|
out_name = local_mp4.name
|
||||||
|
if host is None:
|
||||||
|
out_dir = Path(remote_root) / "out-dir"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
final = out_dir / out_name
|
||||||
|
tmp = out_dir / f".{out_name}.tmp"
|
||||||
|
shutil.copy2(local_mp4, tmp)
|
||||||
|
tmp.replace(final)
|
||||||
|
(Path(remote_root) / "cache" / "videos.json").unlink(missing_ok=True)
|
||||||
|
if old_output and old_output != out_name:
|
||||||
|
(out_dir / old_output).unlink(missing_ok=True)
|
||||||
|
return out_name
|
||||||
remote_tmp = f"{remote_root}/out-dir/.{out_name}.tmp"
|
remote_tmp = f"{remote_root}/out-dir/.{out_name}.tmp"
|
||||||
remote_final = f"{remote_root}/out-dir/{out_name}"
|
remote_final = f"{remote_root}/out-dir/{out_name}"
|
||||||
subprocess.run(["scp", str(local_mp4), f"{host}:{remote_tmp}"], check=True)
|
subprocess.run(["scp", str(local_mp4), f"{host}:{remote_tmp}"], check=True)
|
||||||
|
|
@ -271,7 +324,7 @@ def render_job(local_input: Path, local_out: Path, extra_args: list[str]) -> Pat
|
||||||
return outputs[0]
|
return outputs[0]
|
||||||
|
|
||||||
|
|
||||||
def process_job(host: str, remote_root: str, job: str, work_dir: Path, extra_args: list[str], force: bool) -> None:
|
def process_job(host: str | None, remote_root: str, job: str, work_dir: Path, extra_args: list[str], force: bool) -> None:
|
||||||
job_dir = f"{remote_root}/in-dir/{job}"
|
job_dir = f"{remote_root}/in-dir/{job}"
|
||||||
article_id = read_article_id(host, job_dir)
|
article_id = read_article_id(host, job_dir)
|
||||||
state = read_state(host, job_dir)
|
state = read_state(host, job_dir)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue