Commit e794a441 by PLN (Algolia)

feat(release): Slopmotion renders the release videos, not a still I found lying around

I had `master yt render` wrap each track in a still image, and picked that still
from `output/*.jpg`. PLN: "i dont like these covers, wait where are tehse genai
images from? these are not great sources! we wanna use slopmotion for videos
remember?" Both objections land, and the first is the one worth recording:

  * Those nine illustrations are UNTRACKED and have no recorded provenance.
    `6c3272c9 refactor: Remove covers from git` had deliberately taken covers out
    of this repo, so "sitting in output/" was evidence against using them, not
    for it. I built a per-bank rights ledger for the audio this same session —
    one that refuses a sample bank precisely because its origin is unrecorded —
    and then reached for pictures off the floor. The discipline has to apply to
    every asset in a release or it is not a discipline.
  * Slopmotion already does this properly, already supports `--shape landscape`
    at 1920x1080 explicitly "for YouTube", and has already produced a real
    7m26s landscape render. Nothing needed inventing.

So this bridges the release plan to `visuals/slop/render_slop_clip.mjs`, naming
its output exactly what `tidal_ears.yt.video_path` produces — imported from the
adapter rather than reimplemented, because two copies of a filename rule is how
a renderer and an uploader come to disagree about which file is track 7.

## Realtime is the whole scheduling story

Slopmotion captures in realtime by design: Hydra's clock is wall time, so
frame-stepping runs motion ~3x fast and drifts off the audio it reacts to. A
full-length render therefore costs the track's own duration. OPAL-26 measures
**2h25m** of headless Chromium for the landscape shape — 1h12m of continuous mix
plus 1h13m of tracks. Too long for a shell job that dies with its terminal, so
`--unit` writes a `systemd --user` service with `TimeoutStartSec=0`. Renders are
resumable, so an interruption costs one clip.

Preflight refuses before spending any of that: node >= 22 (system node is 16 and
fails), the dev server actually answering on :5173, every audio file present,
the playset known to the 24-pack catalog.

## The join that had to stay explicit

`clip_ideas.json` agrees with the release plan on neither axis. Its titles are
shorter or French ("Sunshine", "La Revolution Sera Samplee") and its
`setlist_pos` follows an EARLIER running order — its 4 is Take five Drops where
the plan's performance 4 is Am i Doing it Right. So position is not a join and
title equality only catches 4 of 6 live ideas. Fuzzy matching would be worse
than nothing: it puts a look on the wrong track silently. A two-entry alias
table takes it to 6 of 15; the other 9 report no playset and the tool refuses to
guess, because which visual goes with which track is PLN's call.
parent 85123f6a
#!/usr/bin/env python3
"""render_release_videos — the release's videos come from Slopmotion, not a still.
I got this wrong first: `master yt render` wrapped each track in a still image,
and I picked that still from `output/*.jpg` — nine illustrations of unknown
provenance that `6c3272c refactor: Remove covers from git` had deliberately
taken OUT of the repo. PLN: *"i dont like these covers, wait where are tehse
genai images from? these are not great sources! we wanna use slopmotion for
videos remember?"* Both objections stand. Untracked images with no recorded
origin are exactly what the sample-rights ledger exists to refuse, and applying
that discipline to audio while grabbing pictures off the floor is not a policy.
So: Slopmotion (`../../visuals/slop/render_slop_clip.mjs`, Kevin's
`hydra-live-hexa`) renders the video, and this bridges it to the release plan —
same plan the SoundCloud and YouTube adapters read, so nothing forks. It names
its output exactly what `tidal_ears.yt.video_path` expects, so `master yt
upload --videos <dir>` finds it with no translation step.
## The cost is wall time, and it is not small
Slopmotion captures in REALTIME, on purpose: Hydra's animation clock is wall
time, so frame-stepping plays motion ~3x fast and drifts off the audio it is
reacting to. A full-length render therefore costs the track's own duration, per
shape. OPAL-26 is 4361 s of continuous mix plus 4361 s of tracks — about 2h25 of
headless Chromium for the landscape shape alone.
That is far too long for a session-bound background job, so `--unit` emits a
`systemd --user` service instead of pretending a shell job will survive. Renders
are resumable: an existing output is skipped, so an interrupted batch costs only
the clip it was in the middle of.
# preflight only — checks node 22, the dev server, and the plan
python3 render_release_videos.py PLAN --videos DIR --check
# render, resumable
python3 render_release_videos.py PLAN --videos DIR --playset obsidian-tide
# or hand it to systemd, because 2h25 outlives a terminal
python3 render_release_videos.py PLAN --videos DIR --unit
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from pathlib import Path
TIDAL = Path(__file__).resolve().parents[2]
SLOP = TIDAL / "visuals/slop/render_slop_clip.mjs"
SLOP_OUT = TIDAL / "visuals/slop/out"
IDEAS = TIDAL / "visuals/slop/clip_ideas.json"
HEXA = Path(os.environ.get("HEXA_DIR", "/home/pln/Work/Sound/hydra-live-hexa"))
PACKS = HEXA / "src/data/playsetPacks.json"
BASE_URL = os.environ.get("SLOP_BASE_URL", "http://localhost:5173")
NVM_NODE = Path.home() / ".nvm/versions/node"
SHAPES = {"landscape": "YouTube 1920x1080",
"square": "feed 1080x1080",
"vertical": "reels 1080x1920"}
# ---------------------------------------------------------------- naming
# Deliberately imported from the adapter rather than reimplemented: two copies
# of a filename rule is how a renderer and an uploader come to disagree about
# which file is track 7.
def _yt():
sys.path.insert(0, "/home/pln/Work/Sound/tidal-ears/src")
from tidal_ears import yt
return yt
def node22() -> str | None:
"""The newest local node >= 22. The renderer needs it; system node is 16."""
if not NVM_NODE.exists():
return None
cands = []
for d in NVM_NODE.iterdir():
try:
major = int(d.name.lstrip("v").split(".")[0])
except ValueError:
continue
if major >= 22 and (d / "bin/node").exists():
cands.append((major, d / "bin/node"))
return str(max(cands)[1]) if cands else None
def dev_server_up() -> bool:
import urllib.error
import urllib.request
try:
with urllib.request.urlopen(BASE_URL, timeout=3) as r:
return r.status < 500
except Exception:
return False
def playsets() -> set[str]:
if not PACKS.exists():
return set()
d = json.loads(PACKS.read_text())
items = d if isinstance(d, list) else d.get("packs", [])
return {p.get("slug") or p.get("name") for p in items if isinstance(p, dict)}
# `clip_ideas.json` predates the release plan and agrees with it on neither
# axis. Its titles are shorter or in French ("Sunshine", "La Revolution Sera
# Samplee"), and its `setlist_pos` follows an EARLIER running order — its 4 is
# Take five Drops where the plan's performance 4 is Am i Doing it Right. So
# neither title equality nor position is a safe join, and fuzzy title matching
# is worse than no join at all: it puts a playset on the wrong track silently.
#
# Hence an explicit, auditable alias table. Short, checked by eye once, and it
# fails loudly (the clip reports no playset) rather than guessing.
IDEA_ALIASES = {
"Sunshine": "You My Sunshine",
"La Revolution Sera Samplee": "REVOLUTION",
}
def idea_playsets() -> dict[str, list[str]]:
"""release title -> the playsets that clip idea already chose, if any.
Only 7 of the 15 performed tracks have a clip idea, and one of those is
Desire, which is cut from the release — so this covers a minority even after
aliasing. The rest fall back to `--playset`, and that fallback is a look
decision rather than a default the tool should quietly make for PLN.
"""
if not IDEAS.exists():
return {}
d = json.loads(IDEAS.read_text())
out = {}
for i in d.get("ideas", []):
t = i.get("opal_title")
ps = (i.get("visual") or {}).get("playsets") or []
if t and ps:
out[IDEA_ALIASES.get(t, t)] = ps
return out
def jobs(plan: dict, videos: Path, which: str) -> list[dict]:
yt = _yt()
todo = yt.entries(plan, which)
ideas = idea_playsets()
out, pos = [], 0
for e in todo:
if not e.get("_is_continuous_mix"):
pos += 1
dest = yt.video_path(videos, e, plan, pos)
out.append({
"title": e["title"],
"audio": Path(e["audio"]),
"dest": dest,
"stem": dest.stem,
"dur": float(e.get("_duration_s") or 0.0),
"idea_playsets": ideas.get(e["title"]),
"is_mix": bool(e.get("_is_continuous_mix")),
})
return out
def hms(s: float) -> str:
s = int(round(s))
return f"{s // 3600}h{(s % 3600) // 60:02d}m" if s >= 3600 else f"{s // 60}m{s % 60:02d}s"
def preflight(js: list[dict], shape: str, playset: str | None) -> list[str]:
"""Everything that would make the batch fail, found before it starts.
A realtime renderer that dies on argument three has cost real minutes, so
every check that can happen up front happens up front.
"""
bad = []
if not SLOP.exists():
bad.append(f"renderer missing: {SLOP}")
if node22() is None:
bad.append("no node >= 22 in ~/.nvm (system node is 16 and fails)")
if not dev_server_up():
bad.append(f"Slopmotion dev server not answering at {BASE_URL}\n"
f" start it: cd {HEXA} && nvm use 22 && npm run dev")
if shape not in SHAPES:
bad.append(f"unknown shape {shape!r}; have {', '.join(SHAPES)}")
have = playsets()
if playset and have and playset not in have:
bad.append(f"unknown playset {playset!r}; {len(have)} available")
for j in js:
if not j["audio"].exists():
bad.append(f"audio missing: {j['audio'].name}")
if not playset:
missing = [j["title"] for j in js if not j["idea_playsets"]]
if missing:
bad.append(f"{len(missing)} clip(s) have no playset from clip_ideas.json "
f"and no --playset was given — that is a look decision, so "
f"it is not being guessed:\n "
+ ", ".join(m[:26] for m in missing[:6])
+ (" …" if len(missing) > 6 else ""))
return bad
def render_one(j: dict, shape: str, playset: str, node: str,
fps: int, verbose: bool = True) -> bool:
rel = j["audio"].relative_to(TIDAL) if j["audio"].is_relative_to(TIDAL) \
else j["audio"]
cmd = [node, str(SLOP),
"--audio", str(rel),
"--dur", "full",
"--shape", shape,
"--fps", str(fps),
"--name", j["stem"],
"--playset", playset]
if verbose:
print(f" {' '.join(cmd[1:])}")
r = subprocess.run(cmd, cwd=str(TIDAL))
if r.returncode != 0:
print(f" ! renderer exited {r.returncode}")
return False
produced = SLOP_OUT / f"{j['stem']}_{shape}.mp4"
if not produced.exists():
print(f" ! renderer reported success but {produced.name} is not there")
return False
j["dest"].parent.mkdir(parents=True, exist_ok=True)
# Hardlink when we can: these are hundreds of megabytes each, and the slop
# out/ copy is worth keeping as the renderer's own record.
try:
if j["dest"].exists():
j["dest"].unlink()
os.link(produced, j["dest"])
except OSError:
shutil.copy2(produced, j["dest"])
return True
def write_unit(js: list[dict], a) -> Path:
"""A systemd --user unit, because 2h25 of rendering outlives a terminal.
Not Type=oneshot with a shell loop: the point is that closing the session,
or this agent's own process going away, must not take the render with it.
"""
unit = Path.home() / ".config/systemd/user/parvagues-slop-release.service"
unit.parent.mkdir(parents=True, exist_ok=True)
argv = [sys.executable, str(Path(__file__).resolve()), str(a.plan),
"--videos", str(a.videos), "--shape", a.shape,
"--which", a.which, "--fps", str(a.fps)]
if a.playset:
argv += ["--playset", a.playset]
total = sum(j["dur"] for j in js)
unit.write_text(f"""[Unit]
Description=ParVagues — Slopmotion release render ({len(js)} clips, ~{hms(total)})
After=network.target
[Service]
Type=oneshot
WorkingDirectory={TIDAL}
Environment=HEXA_DIR={HEXA}
Environment=SLOP_BASE_URL={BASE_URL}
ExecStart={' '.join(argv)}
# Realtime capture: the batch takes as long as the music does. No timeout.
TimeoutStartSec=0
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
""")
return unit
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("plan")
ap.add_argument("--videos", required=True, help="where yt upload will look")
ap.add_argument("--shape", default="landscape", choices=list(SHAPES))
ap.add_argument("--which", default="all", choices=["set", "album", "all"])
ap.add_argument("--playset", default=None,
help="one playset for every clip; without it, each clip must "
"have one from clip_ideas.json")
ap.add_argument("--fps", type=int, default=30)
ap.add_argument("--check", action="store_true", help="preflight and stop")
ap.add_argument("--unit", action="store_true",
help="write a systemd --user unit instead of rendering")
ap.add_argument("--overwrite", action="store_true")
a = ap.parse_args()
plan = json.loads(Path(a.plan).read_text())
videos = Path(a.videos)
js = jobs(plan, videos, a.which)
done = [j for j in js if j["dest"].exists() and not a.overwrite]
left = [j for j in js if j not in done]
total = sum(j["dur"] for j in left)
print(f"{plan['album']} · {SHAPES[a.shape]} · {len(js)} clip(s)")
if done:
print(f" {len(done)} already rendered, skipping")
print(f" {len(left)} to render — ~{hms(total)} of REALTIME capture")
for j in left:
src = a.playset or (j["idea_playsets"][0] if j["idea_playsets"] else "—")
print(f" {hms(j['dur']):>7} {j['stem'][:44]:<44} {src}")
bad = preflight(left, a.shape, a.playset)
if bad:
print(f"\n{len(bad)} thing(s) to settle first:")
for b in bad:
print(f" - {b}")
return 1
print("\npreflight: clean")
if a.check:
return 0
if a.unit:
u = write_unit(left, a)
print(f"\nwrote {u}\n"
f" systemctl --user daemon-reload\n"
f" systemctl --user start parvagues-slop-release\n"
f" journalctl --user -fu parvagues-slop-release")
return 0
node = node22()
ok = 0
for n, j in enumerate(left, 1):
ps = a.playset or j["idea_playsets"][0]
print(f"\n[{n}/{len(left)}] {j['title']} ({hms(j['dur'])}, {ps})")
if render_one(j, a.shape, ps, node, a.fps):
ok += 1
print(f" ✓ {j['dest'].name}")
print(f"\n{ok}/{len(left)} rendered into {videos}")
return 0 if ok == len(left) else 1
if __name__ == "__main__":
sys.exit(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment