READY page: LED CROSS checks the Resolume columns Sunday's slides call up
- For every item in the plan's ProPresenter playlist: the macros its slides fire (read on the ProP Mac from Configuration/Macros and the .pro files), the RossTalk "CC page/row/col" each macro sends to Companion, the Companion buttons that one presses in turn, down to Resolume's /composition/columns/N/connect. Each such column must hold a clip on some layer (one whose ignore-column-trigger is not On), and Resolume must be on the Service deck. - COL N EMPTY / COL N MISSING name the column and the items that use it. The hold only puts Resolume back on its deck; it never plays a clip. "Something is playing" is no longer checked. - ready.prop_host replaces ready.led_clip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+195
-64
@@ -15,14 +15,15 @@ snap.ready, which the READY page keys draw (check type "ready" in status_panel.p
|
||||
the tracks play at that song's start (worked out from the set's LTC clip and tempo map)
|
||||
backgrounds every song's slides have a background (ProPresenter labels a slide with
|
||||
its media's file name)
|
||||
led_cross Resolume is on the Service deck and something is playing on the cross
|
||||
led_cross every Resolume column Sunday's slides call up holds content, on the Service deck
|
||||
(slide -> macro -> RossTalk "CC page/row/col" to Companion -> Resolume column)
|
||||
lineup the Band Lineup board (ProdDash) shows this plan and exactly the people on it
|
||||
mics (a tap-to-confirm key) which mics this weekend needs, from PCO
|
||||
track_bundle every location has this weekend's Ableton project folder on the shared drive
|
||||
prop_bundle a ProPresenter bundle was saved to the locations' folder this week
|
||||
|
||||
What can be put right is put right by a hold (fix kind "ready", run_action below): re-sync
|
||||
the lineup from PCO, put the cross background back up, copy the tracks to a location that
|
||||
the lineup from PCO, put Resolume back on its deck, copy the tracks to a location that
|
||||
has none, or simply look again now.
|
||||
|
||||
Nothing here writes to Companion, ProPresenter or Ableton's set; the Ableton set is read
|
||||
@@ -52,8 +53,6 @@ MEDIA = re.compile(r"\.(mp4|mov|m4v|mxf|mpg|mpeg|avi|webm|jpg|jpeg|png|gif|heic|
|
||||
|
||||
WAKE = threading.Event() # set by a hold: look again now instead of waiting for the interval
|
||||
LAST = {} # the latest look (run_action needs the plan's date)
|
||||
MEMORY = {} # what is worth remembering across looks: the cross's clip ("led_clip")
|
||||
MEMORY_FILE = "ready-memory.json"
|
||||
|
||||
|
||||
class ReadyError(Exception):
|
||||
@@ -291,7 +290,7 @@ def prop_look(rc, plan):
|
||||
try:
|
||||
p = (http_json(base + "/v1/presentation/" + uuid, timeout=6) or {}).get("presentation") or {}
|
||||
slides = [s for g in p.get("groups") or [] for s in g.get("slides") or []]
|
||||
cache[uuid] = {"name": (p.get("id") or {}).get("name"), "type": p.get("type"),
|
||||
cache[uuid] = {"name": (p.get("id") or {}).get("name"), "type": p.get("type"), "path": p.get("presentation_path"),
|
||||
"timeline": bool(p.get("has_timeline")), "cues": p.get("total_cues") or 0,
|
||||
"slides": [{"enabled": s.get("enabled", True), "label": s.get("label") or "",
|
||||
"text": s.get("text") or "", "notes": s.get("notes") or ""} for s in slides]}
|
||||
@@ -735,31 +734,181 @@ def eval_lineup(rc, plan, state):
|
||||
return line
|
||||
|
||||
|
||||
# --- Resolume (the LED cross) ------------------------------------------------------------------
|
||||
# --- Resolume: the columns Sunday's slides call up -------------------------------------------
|
||||
# A ProPresenter slide fires a macro (the .pro holds the macro's UUID); the macro sends RossTalk
|
||||
# "CC page/row/column" to Companion; that Companion button (and the buttons it presses in turn)
|
||||
# sends Resolume "/composition/columns/N/connect". Every column found that way must hold content.
|
||||
|
||||
def connected_clips(comp):
|
||||
"""[(layer number, clip number, name)] of every clip playing in the composition (1-based, as the API wants)."""
|
||||
out = []
|
||||
for li, layer in enumerate(comp.get("layers") or []):
|
||||
for ci, clip in enumerate(layer.get("clips") or []):
|
||||
state = ((clip.get("connected") or {}).get("value") or "")
|
||||
if state.startswith("Connected"):
|
||||
out.append((li + 1, ci + 1, ((clip.get("name") or {}).get("value") or "")))
|
||||
MACRO_SCRIPT = r"""
|
||||
import json, os, subprocess
|
||||
P = json.loads(%r)
|
||||
def show_dir():
|
||||
try:
|
||||
d = subprocess.run(["defaults", "read", "com.renewedvision.propresenter", "applicationShowDirectory"],
|
||||
capture_output=True, text=True, timeout=5).stdout.strip()
|
||||
if d:
|
||||
return os.path.expanduser(d)
|
||||
except Exception:
|
||||
pass
|
||||
return os.path.expanduser("~/Documents/ProPresenter")
|
||||
def varint(b, i):
|
||||
s = r = 0
|
||||
while True:
|
||||
x = b[i]; i += 1; r |= (x & 0x7f) << s
|
||||
if not x & 0x80:
|
||||
return r, i
|
||||
s += 7
|
||||
def fields(b):
|
||||
out, i = [], 0
|
||||
while i < len(b):
|
||||
k, i = varint(b, i); num, wt = k >> 3, k & 7
|
||||
if wt == 0: v, i = varint(b, i)
|
||||
elif wt == 1: v, i = b[i:i + 8], i + 8
|
||||
elif wt == 5: v, i = b[i:i + 4], i + 4
|
||||
elif wt == 2:
|
||||
n, i = varint(b, i); v = b[i:i + n]; i += n
|
||||
else: raise ValueError(wt)
|
||||
out.append((num, wt, v))
|
||||
return out
|
||||
def get(b, *path):
|
||||
for p in path:
|
||||
if not isinstance(b, bytes):
|
||||
return None
|
||||
f = [v for n, w, v in fields(b) if n == p]
|
||||
if not f:
|
||||
return None
|
||||
b = f[0]
|
||||
return b if isinstance(b, bytes) else None
|
||||
macros, raw = {}, {}
|
||||
for num, wt, v in fields(open(os.path.join(show_dir(), "Configuration", "Macros"), "rb").read()):
|
||||
if num != 2 or wt != 2:
|
||||
continue
|
||||
try:
|
||||
uuid = get(v, 1, 1).decode(); name = (get(v, 2) or b"").decode()
|
||||
except Exception:
|
||||
continue
|
||||
cc = []
|
||||
for n, w, act in fields(v):
|
||||
comm = get(act, 32) if n == 4 and w == 2 else None
|
||||
if comm is None:
|
||||
continue
|
||||
dev, fmt = (get(comm, 1, 2) or b"").decode(), (get(comm, 2) or b"").decode()
|
||||
for n2, w2, cmd in fields(comm):
|
||||
if n2 == 4 and w2 == 2:
|
||||
cc.append([dev, fmt, (get(cmd, 2) or b"").decode()])
|
||||
macros[uuid] = {"name": name, "cc": cc}
|
||||
raw[uuid] = v
|
||||
for u, v in raw.items(): # a macro that runs other macros
|
||||
macros[u]["calls"] = [o for o in raw if o != u and o.encode() in v]
|
||||
used = {}
|
||||
for p in P["paths"]:
|
||||
try:
|
||||
b = open(p, "rb").read()
|
||||
except OSError:
|
||||
used[p] = None
|
||||
continue
|
||||
used[p] = [u for u in macros if u.encode() in b]
|
||||
print(json.dumps({"macros": macros, "used": used}))
|
||||
"""
|
||||
|
||||
COLUMN = re.compile(r"/composition/columns/(\d+)/connect")
|
||||
LOCATION = re.compile(r"^\s*(\d+)/(\d+)/(\d+)\s*$")
|
||||
PRESSES = ("button_pressrelease", "button_press", "button_release", "button_pressrelease_condition")
|
||||
|
||||
|
||||
def eval_resolume(rc, comp, memory):
|
||||
def companion_page(cfg, n):
|
||||
"""One Companion page as Companion exports it (read-only)."""
|
||||
comp = cfg.get("companion") or {}
|
||||
url = "http://%s:%d/int/export/page/%d?includeSecrets=false" % (comp.get("host", "127.0.0.1"), comp.get("port", 8000), n)
|
||||
with urllib.request.urlopen(url, timeout=6) as r:
|
||||
raw = r.read()
|
||||
try:
|
||||
raw = __import__("gzip").decompress(raw)
|
||||
except OSError:
|
||||
pass
|
||||
return json.loads(raw).get("page") or {}
|
||||
|
||||
|
||||
def button_columns(get_page, location, seen=None):
|
||||
"""The Resolume columns a Companion button connects, following the buttons it presses."""
|
||||
seen = set() if seen is None else seen
|
||||
m = LOCATION.match(location or "")
|
||||
if not m or location in seen or len(seen) > 40:
|
||||
return set()
|
||||
seen.add(location)
|
||||
page, row, col = (int(x) for x in m.groups())
|
||||
ctl = (((get_page(page) or {}).get("controls") or {}).get(str(row)) or {}).get(str(col)) or {}
|
||||
cols = set()
|
||||
|
||||
def walk(actions):
|
||||
for a in actions or []:
|
||||
opts = {k: (v.get("value") if isinstance(v, dict) else v) for k, v in (a.get("options") or {}).items()}
|
||||
for v in opts.values():
|
||||
for hit in COLUMN.findall(str(v)):
|
||||
cols.add(int(hit))
|
||||
if "olumn" in str(a.get("definitionId")) and str(opts.get("column", "")).isdigit():
|
||||
cols.add(int(opts["column"]))
|
||||
if a.get("connectionId") == "internal" and a.get("definitionId") in PRESSES:
|
||||
cols.update(button_columns(get_page, str(opts.get("location", "")), seen))
|
||||
for kids in (a.get("children") or {}).values():
|
||||
walk(kids)
|
||||
for step in (ctl.get("steps") or {}).values():
|
||||
for actions in (step.get("action_sets") or {}).values():
|
||||
walk(actions)
|
||||
return cols
|
||||
|
||||
|
||||
def macro_columns(macros, uuid, get_page, seen=None):
|
||||
"""The columns one ProPresenter macro calls up (through the Companion buttons it presses)."""
|
||||
seen = set() if seen is None else seen
|
||||
if uuid in seen or uuid not in macros:
|
||||
return set()
|
||||
seen.add(uuid)
|
||||
cols = set()
|
||||
for dev, fmt, value in macros[uuid].get("cc") or []:
|
||||
if fmt.strip().upper().startswith("CC"):
|
||||
cols |= button_columns(get_page, value.replace(":", "/"))
|
||||
for other in macros[uuid].get("calls") or []:
|
||||
cols |= macro_columns(macros, other, get_page, seen)
|
||||
return cols
|
||||
|
||||
|
||||
def column_has_content(comp, n):
|
||||
"""Column n (1-based) holds a clip on some layer that a column trigger plays."""
|
||||
for layer in comp.get("layers") or []:
|
||||
clips = layer.get("clips") or []
|
||||
if n - 1 < len(clips):
|
||||
c = clips[n - 1]
|
||||
ignore = (c.get("ignorecolumntrigger") or {}).get("value") # "Layer Determined" / "On" / "Off"
|
||||
if ((c.get("connected") or {}).get("value") or "Empty") != "Empty" and ignore not in ("On", True):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def eval_columns(rc, prop, found, get_page, comp):
|
||||
"""Every Resolume column the plan's ProPresenter items call up holds content, on the right deck."""
|
||||
line = Line()
|
||||
deck = next((d for d in comp.get("decks") or [] if (d.get("selected") or {}).get("value")), None)
|
||||
want = rc.get("resolume_deck", "")
|
||||
if want and (deck is None or ((deck.get("name") or {}).get("value") or "") != want):
|
||||
line.fault(ERR, "WRONG DECK", "Resolume is on deck %s, not %s" % (((deck or {}).get("name") or {}).get("value"), want))
|
||||
playing = connected_clips(comp)
|
||||
if not playing:
|
||||
line.fault(ERR, "NOTHING ON", "no clip is playing in %s" % ((comp.get("name") or {}).get("value") or "the composition"))
|
||||
else:
|
||||
memory["led_clip"] = playing[0][2]
|
||||
line.info = short(playing[0][2], 30)
|
||||
line.fault(ERR, "WRONG DECK", "Resolume is on deck %s, not %s: the columns would be another deck's" % (
|
||||
((deck or {}).get("name") or {}).get("value"), want))
|
||||
macros, used = found.get("macros") or {}, found.get("used") or {}
|
||||
wanted = {} # column -> [items that call it up]
|
||||
for it in prop.get("items") or []:
|
||||
path = (it.get("pres") or {}).get("path")
|
||||
for uuid in used.get(path) or []:
|
||||
for col in macro_columns(macros, uuid, get_page):
|
||||
wanted.setdefault(col, []).append(it["name"])
|
||||
columns = comp.get("columns") or []
|
||||
for col in sorted(wanted):
|
||||
name = ((columns[col - 1].get("name") or {}).get("value") if col <= len(columns) else "") or ""
|
||||
who = ", ".join(dict.fromkeys(wanted[col]))
|
||||
if col > len(columns):
|
||||
line.fault(ERR, "COL %d MISSING" % col, "%s call up column %d; Resolume has %d columns" % (who, col, len(columns)))
|
||||
elif not column_has_content(comp, col):
|
||||
line.fault(ERR, "COL %d EMPTY" % col, "column %d (%s) is empty in Resolume; %s call it up" % (col, name, who))
|
||||
line.info = "%d COLUMNS OK" % len(wanted) if wanted else "NO COLUMNS USED"
|
||||
return line
|
||||
|
||||
|
||||
@@ -812,25 +961,6 @@ class Line:
|
||||
"info": self.info}
|
||||
|
||||
|
||||
def load_memory(data_dir):
|
||||
try:
|
||||
return json.load(open(os.path.join(data_dir, MEMORY_FILE))) if data_dir else {}
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def save_memory(data_dir, memory):
|
||||
if not data_dir:
|
||||
return
|
||||
try:
|
||||
tmp = os.path.join(data_dir, MEMORY_FILE + ".tmp")
|
||||
with open(tmp, "w") as f:
|
||||
json.dump(memory, f)
|
||||
os.replace(tmp, os.path.join(data_dir, MEMORY_FILE))
|
||||
except OSError as e:
|
||||
log.warning("%s: %s", MEMORY_FILE, e)
|
||||
|
||||
|
||||
def look(cfg, now=None, data_dir=None, sources=None):
|
||||
"""Everything the READY page shows, as {"time", "plan", "items": {item: {"faults", "details", "info"}}}.
|
||||
`sources` replaces the readers (tests)."""
|
||||
@@ -840,12 +970,12 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
ableton=lambda plan: ableton_look(cfg, rc, plan),
|
||||
lineup=lambda: http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6),
|
||||
resolume=lambda: http_json(rc.get("resolume_url", "").rstrip("/") + "/api/v1/composition", timeout=6),
|
||||
macros=lambda paths: run_remote(cfg, rc["prop_host"], MACRO_SCRIPT % json.dumps({"paths": paths}), timeout=60),
|
||||
companion=lambda n: companion_page(cfg, n),
|
||||
prop_bundle=lambda: run_remote(cfg, rc["prop_bundle_host"], BUNDLE_SCRIPT % json.dumps(
|
||||
{"folder": rc.get("prop_bundle_folder", ""), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
|
||||
src.update(sources or {})
|
||||
lines = {}
|
||||
if not MEMORY:
|
||||
MEMORY.update(load_memory(data_dir))
|
||||
|
||||
def failed(names, err):
|
||||
msg = str(err) if isinstance(err, ReadyError) else "CHECK FAILED"
|
||||
@@ -862,13 +992,31 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
("lights", rc.get("tracks_host")), ("prop", rc.get("prop_url")),
|
||||
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
|
||||
("lineup", rc.get("lineup_url")), ("mics", True),
|
||||
("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host"))) if on], e)
|
||||
("track_bundle", rc.get("locations")), ("prop_bundle", rc.get("prop_bundle_host")),
|
||||
("led_cross", rc.get("resolume_url") and rc.get("prop_host"))) if on], e)
|
||||
if plan is not None:
|
||||
prop = None
|
||||
if rc.get("prop_url"):
|
||||
try:
|
||||
lines.update(eval_prop(rc, plan, src["prop"](plan)))
|
||||
prop = src["prop"](plan)
|
||||
lines.update(eval_prop(rc, plan, prop))
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed(["prop", "timecode", "backgrounds"], e)
|
||||
failed(["prop", "timecode", "backgrounds"] + (["led_cross"] if rc.get("resolume_url") and rc.get("prop_host") else []), e)
|
||||
if rc.get("resolume_url") and rc.get("prop_host") and prop is not None:
|
||||
try:
|
||||
pages = {}
|
||||
|
||||
def get_page(n):
|
||||
if n not in pages:
|
||||
try:
|
||||
pages[n] = src["companion"](n)
|
||||
except (OSError, ValueError):
|
||||
pages[n] = {}
|
||||
return pages[n]
|
||||
paths = sorted({(it.get("pres") or {}).get("path") for it in prop["items"]} - {None})
|
||||
lines["led_cross"] = eval_columns(rc, prop, src["macros"](paths), get_page, src["resolume"]())
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed(["led_cross"], e if isinstance(e, ReadyError) else ReadyError("RESOLUME UNREACHABLE"))
|
||||
if rc.get("tracks_host"):
|
||||
try:
|
||||
al = src["ableton"](plan)
|
||||
@@ -895,14 +1043,6 @@ def look(cfg, now=None, data_dir=None, sources=None):
|
||||
lines["prop_bundle"] = eval_prop_bundle(rc, src["prop_bundle"](), week_start(now, rc.get("reset_day", "mon")))
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed(["prop_bundle"], e)
|
||||
if rc.get("resolume_url"):
|
||||
try:
|
||||
before = dict(MEMORY)
|
||||
lines["led_cross"] = eval_resolume(rc, src["resolume"](), MEMORY)
|
||||
if MEMORY != before:
|
||||
save_memory(data_dir, MEMORY)
|
||||
except Exception as e: # noqa: BLE001
|
||||
failed(["led_cross"], e if isinstance(e, ReadyError) else ReadyError("RESOLUME UNREACHABLE"))
|
||||
out = {"time": now, "items": {k: v.as_dict() for k, v in lines.items()}}
|
||||
if plan is not None:
|
||||
out["plan"] = {"id": plan["id"], "title": plan["title"], "dates": plan["dates"], "time": plan["time"]}
|
||||
@@ -998,16 +1138,7 @@ def run_action(cfg, action, chk=None, sleep=time.sleep):
|
||||
log.info("FIX Resolume deck %s selected", want)
|
||||
sleep(1)
|
||||
comp = http_json(base, timeout=6) or {}
|
||||
if connected_clips(comp):
|
||||
return True
|
||||
name = rc.get("led_clip") or MEMORY.get("led_clip") # set in config, else the last one seen playing
|
||||
for li, layer in enumerate(comp.get("layers") or []):
|
||||
for ci, clip in enumerate(layer.get("clips") or []):
|
||||
if name and ((clip.get("name") or {}).get("value") or "") == name:
|
||||
http_json("%s/layers/%d/clips/%d/connect" % (base, li + 1, ci + 1), method="POST", timeout=6)
|
||||
log.info("FIX LED cross: %s playing again (layer %d clip %d)", name, li + 1, ci + 1)
|
||||
return True
|
||||
return "NO CLIP KNOWN"
|
||||
return True # an empty column is filled by hand in Resolume
|
||||
if action == "ltc_to_pco":
|
||||
return write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id"))
|
||||
if action == "copy_tracks":
|
||||
|
||||
Reference in New Issue
Block a user