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:
2026-10-15 14:43:49 -04:00
co-authored by Claude Opus 5.5
parent 5adab6c518
commit c9ffbc6828
6 changed files with 264 additions and 88 deletions
+1 -1
View File
@@ -152,7 +152,7 @@ everything on the page at once.
| PROP | the ProPresenter playlist for the plan's date has every PCO item. **MC n/m SLIDES**: PCO lists more announcements than there are slides. **MC SLIDES OLD?**: the slide notes still list last week's announcements. **BAPTISM: NO SAMANTHA**: a person PCO lists has no slide | looks again | | PROP | the ProPresenter playlist for the plan's date has every PCO item. **MC n/m SLIDES**: PCO lists more announcements than there are slides. **MC SLIDES OLD?**: the slide notes still list last week's announcements. **BAPTISM: NO SAMANTHA**: a person PCO lists has no slide | looks again |
| TIMECODE | every song's presentation has a timeline in ProPresenter, and PCO's `LTC` note for every song is the timecode the tracks actually play at that song's start. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again | | TIMECODE | every song's presentation has a timeline in ProPresenter, and PCO's `LTC` note for every song is the timecode the tracks actually play at that song's start. That timecode is worked out from the set: where its locator ("2 - REJOICE AGAIN - Bb") sits, through the tempo map, on the `LTC` track's file (`LTC_00000000_..._30fps`). **NO LTC** / **LTC WRONG** name the song. | writes the tracks' timecode into each song's `LTC` note in Planning Center where it is missing or different (nothing is written when they already match), then looks again |
| BKGDS | every song has at least one slide with a background | looks again | | BKGDS | every song has at least one slide with a background | looks again |
| LED CROSS | Resolume is on the **Service** deck and a clip is playing | selects the Service deck and plays the cross background again (the one in `"led_clip"`, else the last one seen playing) | | LED CROSS | every Resolume column Sunday's playlist calls up has content: each slide's macro (read from the ProPresenter Mac's `Macros` and `.pro` files) sends RossTalk `CC page/row/col` to Companion, that button (and the buttons it presses, e.g. page 17 → page 6 "Arena") sends `/composition/columns/N/connect`; **COL 11 EMPTY** names the column and the items that use it. Also: Resolume on the **Service** deck. | selects the Service deck, then looks again (an empty column is filled in Resolume) |
| LINEUP TV | ProdDash's Band Lineup shows this plan, nobody who is not on it or who declined, and everyone on the Band and Communication teams | re-syncs the board from Planning Center | | LINEUP TV | ProdDash's Band Lineup shows this plan, nobody who is not on it or who declined, and everyone on the Band and Communication teams | re-syncs the board from Planning Center |
| TRACKS OUT | every location in `"locations"` (AB, SC) has this weekend's project folder | copies NA's project to the locations that have none (as `261018 AB Project`, without `Backup/`). It is copied under a temporary name and renamed at the end, so a half-finished copy never looks sent. It never overwrites a folder that is already there and never runs by itself. | | TRACKS OUT | every location in `"locations"` (AB, SC) has this weekend's project folder | copies NA's project to the locations that have none (as `261018 AB Project`, without `Backup/`). It is copied under a temporary name and renamed at the end, so a half-finished copy never looks sent. It never overwrites a folder that is already there and never runs by itself. |
| PROP OUT | a `.probundle` was saved to `Location Assets/ProPresenter` since Monday | looks again | | PROP OUT | a `.probundle` was saved to `Location Assets/ProPresenter` since Monday | looks again |
+2 -2
View File
@@ -767,7 +767,7 @@
"kind": "ready", "kind": "ready",
"action": "led_cross", "action": "led_cross",
"short": "CROSS", "short": "CROSS",
"label": "put Resolume back on the Service deck and play the cross background again" "label": "put Resolume back on the Service deck, then look at the columns again"
}, },
"ready_copy_tracks": { "ready_copy_tracks": {
"kind": "ready", "kind": "ready",
@@ -1345,7 +1345,7 @@
"lineup_skip_positions": [], "lineup_skip_positions": [],
"resolume_url": "http://10.1.10.206:8080", "resolume_url": "http://10.1.10.206:8080",
"resolume_deck": "Service", "resolume_deck": "Service",
"led_clip": "", "prop_host": "prop",
"prop_bundle_host": "prop", "prop_bundle_host": "prop",
"prop_bundle_folder": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Location Assets/ProPresenter", "prop_bundle_folder": "~/Library/CloudStorage/GoogleDrive-media@waterschurch.org/Shared drives/Location Assets/ProPresenter",
"prop_bundle_pattern": "*.probundle", "prop_bundle_pattern": "*.probundle",
+195 -64
View File
@@ -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) 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 backgrounds every song's slides have a background (ProPresenter labels a slide with
its media's file name) 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 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 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 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 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 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. has none, or simply look again now.
Nothing here writes to Companion, ProPresenter or Ableton's set; the Ableton set is read 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 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) 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): class ReadyError(Exception):
@@ -291,7 +290,7 @@ def prop_look(rc, plan):
try: try:
p = (http_json(base + "/v1/presentation/" + uuid, timeout=6) or {}).get("presentation") or {} 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 []] 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, "timeline": bool(p.get("has_timeline")), "cues": p.get("total_cues") or 0,
"slides": [{"enabled": s.get("enabled", True), "label": s.get("label") or "", "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]} "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 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): MACRO_SCRIPT = r"""
"""[(layer number, clip number, name)] of every clip playing in the composition (1-based, as the API wants).""" import json, os, subprocess
out = [] P = json.loads(%r)
for li, layer in enumerate(comp.get("layers") or []): def show_dir():
for ci, clip in enumerate(layer.get("clips") or []): try:
state = ((clip.get("connected") or {}).get("value") or "") d = subprocess.run(["defaults", "read", "com.renewedvision.propresenter", "applicationShowDirectory"],
if state.startswith("Connected"): capture_output=True, text=True, timeout=5).stdout.strip()
out.append((li + 1, ci + 1, ((clip.get("name") or {}).get("value") or ""))) 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 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() line = Line()
deck = next((d for d in comp.get("decks") or [] if (d.get("selected") or {}).get("value")), None) deck = next((d for d in comp.get("decks") or [] if (d.get("selected") or {}).get("value")), None)
want = rc.get("resolume_deck", "") want = rc.get("resolume_deck", "")
if want and (deck is None or ((deck.get("name") or {}).get("value") or "") != want): 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)) line.fault(ERR, "WRONG DECK", "Resolume is on deck %s, not %s: the columns would be another deck's" % (
playing = connected_clips(comp) ((deck or {}).get("name") or {}).get("value"), want))
if not playing: macros, used = found.get("macros") or {}, found.get("used") or {}
line.fault(ERR, "NOTHING ON", "no clip is playing in %s" % ((comp.get("name") or {}).get("value") or "the composition")) wanted = {} # column -> [items that call it up]
else: for it in prop.get("items") or []:
memory["led_clip"] = playing[0][2] path = (it.get("pres") or {}).get("path")
line.info = short(playing[0][2], 30) 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 return line
@@ -812,25 +961,6 @@ class Line:
"info": self.info} "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): def look(cfg, now=None, data_dir=None, sources=None):
"""Everything the READY page shows, as {"time", "plan", "items": {item: {"faults", "details", "info"}}}. """Everything the READY page shows, as {"time", "plan", "items": {item: {"faults", "details", "info"}}}.
`sources` replaces the readers (tests).""" `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), ableton=lambda plan: ableton_look(cfg, rc, plan),
lineup=lambda: http_json(rc.get("lineup_url", "").rstrip("/") + "/state", timeout=6), 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), 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( 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)) {"folder": rc.get("prop_bundle_folder", ""), "pattern": rc.get("prop_bundle_pattern", "*.probundle")}), timeout=30))
src.update(sources or {}) src.update(sources or {})
lines = {} lines = {}
if not MEMORY:
MEMORY.update(load_memory(data_dir))
def failed(names, err): def failed(names, err):
msg = str(err) if isinstance(err, ReadyError) else "CHECK FAILED" 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")), ("lights", rc.get("tracks_host")), ("prop", rc.get("prop_url")),
("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")), ("timecode", rc.get("prop_url")), ("backgrounds", rc.get("prop_url")),
("lineup", rc.get("lineup_url")), ("mics", True), ("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: if plan is not None:
prop = None
if rc.get("prop_url"): if rc.get("prop_url"):
try: 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 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"): if rc.get("tracks_host"):
try: try:
al = src["ableton"](plan) 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"))) lines["prop_bundle"] = eval_prop_bundle(rc, src["prop_bundle"](), week_start(now, rc.get("reset_day", "mon")))
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
failed(["prop_bundle"], e) 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()}} out = {"time": now, "items": {k: v.as_dict() for k, v in lines.items()}}
if plan is not None: if plan is not None:
out["plan"] = {"id": plan["id"], "title": plan["title"], "dates": plan["dates"], "time": plan["time"]} 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) log.info("FIX Resolume deck %s selected", want)
sleep(1) sleep(1)
comp = http_json(base, timeout=6) or {} comp = http_json(base, timeout=6) or {}
if connected_clips(comp): return True # an empty column is filled by hand in Resolume
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"
if action == "ltc_to_pco": if action == "ltc_to_pco":
return write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id")) return write_ltc(rc, LAST.get("ltc") or [], LAST.get("service_type"), LAST.get("plan_id"))
if action == "copy_tracks": if action == "copy_tracks":
+3 -2
View File
@@ -382,7 +382,8 @@ field("ready.lineup_teams", "list", ["Band", "Communication"], "PCO teams whose
field("ready.lineup_skip_positions", "list", [], "PCO positions the lineup TV does not show.", "Ready") field("ready.lineup_skip_positions", "list", [], "PCO positions the lineup TV does not show.", "Ready")
field("ready.resolume_url", "url", "", "Resolume Arena's web server.", "Ready") field("ready.resolume_url", "url", "", "Resolume Arena's web server.", "Ready")
field("ready.resolume_deck", "str", "", "The deck Resolume must be on.", "Ready") field("ready.resolume_deck", "str", "", "The deck Resolume must be on.", "Ready")
field("ready.led_clip", "str", "", "The cross background a hold plays; blank = the last one seen playing.", "Ready") field("ready.prop_host", "str", None, "The ProPresenter computer: its macros and presentation files are read to find "
"the Resolume columns Sunday's slides call up.", "Ready")
field("ready.prop_bundle_host", "str", None, "The computer that saves the ProPresenter bundle.", "Ready") field("ready.prop_bundle_host", "str", None, "The computer that saves the ProPresenter bundle.", "Ready")
field("ready.prop_bundle_folder", "path", "", "Where the bundle for the locations goes.", "Ready") field("ready.prop_bundle_folder", "path", "", "Where the bundle for the locations goes.", "Ready")
field("ready.prop_bundle_pattern", "str", "*.probundle", "The bundle's file name pattern.", "Ready", advanced=True) field("ready.prop_bundle_pattern", "str", "*.probundle", "The bundle's file name pattern.", "Ready", advanced=True)
@@ -467,7 +468,7 @@ GLOSSARY = {
"MISSING", "SLIDES OLD?", "SLIDES", "NO TC", "NO LTC", "NO BG", "NO PLAYLIST", "OLD PLAN", "SYNC ERROR", "MISSING", "SLIDES OLD?", "SLIDES", "NO TC", "NO LTC", "NO BG", "NO PLAYLIST", "OLD PLAN", "SYNC ERROR",
"NOT ON PLAN", "DECLINED", "WRONG DECK", "NOTHING ON", "NOT SENT", "COPYING TO", "NO PCO LOGIN", "NOT ON PLAN", "DECLINED", "WRONG DECK", "NOTHING ON", "NOT SENT", "COPYING TO", "NO PCO LOGIN",
"PCO UNREACHABLE", "NO PLAN IN PCO", "PROP UNREACHABLE", "LINEUP UNREACHABLE", "RESOLUME UNREACHABLE", "PCO UNREACHABLE", "NO PLAN IN PCO", "PROP UNREACHABLE", "LINEUP UNREACHABLE", "RESOLUME UNREACHABLE",
"UNREACHABLE", "CHECK FAILED", "NO CLIP KNOWN", "TURNED OFF", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX", "UNREACHABLE", "CHECK FAILED", "TURNED OFF", "COL", "COLUMNS OK", "NO COLUMNS USED", "LTC WRONG", "PCO WRITE FAILED", "IN PCO", "NO SET TO COPY", "SYNC SLOW", "NO PLAN YET", "VOX",
], ],
"ask Chris": ["NOT IN COMPANION", "CHECK BUG", "NO DATA", "WATCHDOG DOWN", "ERR", "PROCESS", "WEB"], "ask Chris": ["NOT IN COMPANION", "CHECK BUG", "NO DATA", "WATCHDOG DOWN", "ERR", "PROCESS", "WEB"],
"what a key says while you hold it": [ "what a key says while you hold it": [
+1 -1
View File
@@ -569,7 +569,7 @@
prop: "The ProPresenter playlist for the plan's date has every Planning Center item; MC announcements have a slide each and this week's notes; every person listed for baptisms or dedications has a slide.", prop: "The ProPresenter playlist for the plan's date has every Planning Center item; MC announcements have a slide each and this week's notes; every person listed for baptisms or dedications has a slide.",
timecode: "Every song's presentation has a timeline, and Planning Center's LTC note for every song is the timecode the tracks play at that song's start.", timecode: "Every song's presentation has a timeline, and Planning Center's LTC note for every song is the timecode the tracks play at that song's start.",
backgrounds: "Every song has at least one slide with a background.", backgrounds: "Every song has at least one slide with a background.",
led_cross: "Resolume is on the Service deck and a clip is playing on the cross.", led_cross: "Every Resolume column Sunday's slides call up (slide → macro → Companion → column) holds content, and Resolume is on the Service deck.",
lineup: "The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.", lineup: "The Band Lineup TV shows this plan, nobody who is not on it or declined, and everyone on the band and communication teams.",
track_bundle: "Every location has this weekend's Ableton project folder on the shared drive.", track_bundle: "Every location has this weekend's Ableton project folder on the shared drive.",
prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.", prop_bundle: "A ProPresenter bundle was saved for the locations since Monday.",
+62 -18
View File
@@ -63,7 +63,7 @@ def slide(label="", text="", notes="", enabled=True):
def pres(name, slides, timeline=False, cues=None): def pres(name, slides, timeline=False, cues=None):
return {"name": name, "type": "multitracks" if timeline else "standard", "timeline": timeline, return {"name": name, "path": "/Volumes/MEDIA/ProPresenter/Libraries/%s.pro" % name, "type": "multitracks" if timeline else "standard", "timeline": timeline,
"cues": len(slides) if cues is None else cues, "slides": slides} "cues": len(slides) if cues is None else cues, "slides": slides}
@@ -131,12 +131,43 @@ def lineup(names, plan_id="92013595"):
"slots": [{"index": i, "label": "S%d" % i, "person": {"name": n} if n else None} for i, n in enumerate(names)]}} "slots": [{"index": i, "label": "S%d" % i, "person": {"name": n} if n else None} for i, n in enumerate(names)]}}
def composition(deck="Service", playing=True): def composition(deck="Service", empty=(), ignored=(), columns=12):
"""Resolume's "Crosses": a clip in every column of layer 1 but the `empty` ones; layer 2 all empty."""
clip = lambda n, full, ign: {"name": {"value": "Clip %d" % n if full else ""},
"connected": {"value": "Disconnected" if full else "Empty"},
"ignorecolumntrigger": {"value": "On" if ign else "Layer Determined"}}
return {"name": {"value": "Crosses"}, return {"name": {"value": "Crosses"},
"decks": [{"name": {"value": "Service"}, "selected": {"value": deck == "Service"}}, "decks": [{"name": {"value": "Service"}, "selected": {"value": deck == "Service"}},
{"name": {"value": "Test"}, "selected": {"value": deck == "Test"}}], {"name": {"value": "Test"}, "selected": {"value": deck == "Test"}}],
"layers": [{"clips": [{"name": {"value": "SS_StainedGlass005"}, "connected": {"value": "Disconnected"}}, "columns": [{"name": {"value": "Theme 10 White" if n == 11 else "Column %d" % n}} for n in range(1, columns + 1)],
{"name": {"value": "Stained Glass 11"}, "connected": {"value": "Connected" if playing else "Disconnected"}}]}]} "layers": [{"clips": [clip(n, n not in empty, n in ignored) for n in range(1, columns + 1)]},
{"clips": [clip(n, False, False) for n in range(1, columns + 1)]}]}
# Companion as the chain really is: page 17 "White" presses 6/1/4, page 6's 1/4 connects column 11;
# page 17 "Salvation" presses 6/1/5 (column 12) and 17/2/4 (White again); 17/0/1 presses itself.
COMPANION = {
17: {"controls": {"2": {"4": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "6/1/4"}}}]}}}}},
"4": {"3": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "6/1/5"}}},
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "17/2/4"}}}]}}}}},
"0": {"1": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "internal", "definitionId": "button_pressrelease", "options": {"location": {"value": "17/0/1"}}}]}}}}}}},
6: {"controls": {"1": {"4": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "arena-id", "definitionId": "custom", "options": {"customPath": {"value": "/composition/columns/11/connect"}}}]}}}},
"5": {"steps": {"0": {"action_sets": {"down": [
{"connectionId": "arena-id", "definitionId": "custom", "options": {"customPath": {"value": "/composition/columns/12/connect"}}}]}}}}}}},
}
MACROS = {"U-WHITE": {"name": "White", "cc": [["Stream Deck", "CC x", "17/2/4"]], "calls": []},
"U-SALV": {"name": "Salvation Moment", "cc": [["Stream Deck", "CC x", "17/4/3"]], "calls": []},
"U-LOOP": {"name": "Loop", "cc": [["Stream Deck", "CC x", "17/0/1"]], "calls": ["U-WHITE"]}}
def macros_found(paths):
"""Holy Forever and Rejoice Again fire White; Baptisms fires the macro that runs White too."""
use = {"Holy Forever": ["U-WHITE"], "Rejoice Again": ["U-WHITE"], "Baptisms": ["U-LOOP"]}
return {"macros": MACROS, "used": {p: use.get(p.rsplit("/", 1)[-1][:-4], []) for p in paths}}
def msgs(line): def msgs(line):
@@ -283,11 +314,26 @@ class TestLineupCrossBundle(unittest.TestCase):
self.assertIsNone(rd.best_person("Alex", p["team"])) # two Alexes: nobody for sure self.assertIsNone(rd.best_person("Alex", p["team"])) # two Alexes: nobody for sure
self.assertEqual(rd.name_score("Kevin Mitchell", "Kevin Michel"), 0) self.assertEqual(rd.name_score("Kevin Mitchell", "Kevin Michel"), 0)
def test_led_cross(self): def test_columns_follow_macro_companion_resolume(self):
memory = {} get = lambda n: COMPANION.get(n, {})
self.assertEqual(msgs(rd.eval_resolume(RC, composition(), memory)), []) self.assertEqual(rd.button_columns(get, "17/2/4"), {11})
self.assertEqual(memory["led_clip"], "Stained Glass 11") self.assertEqual(rd.button_columns(get, "17/4/3"), {11, 12}) # through two buttons
self.assertEqual(msgs(rd.eval_resolume(RC, composition("Test", False), {})), ["WRONG DECK", "NOTHING ON"]) self.assertEqual(rd.button_columns(get, "17/0/1"), set()) # presses itself: no loop
self.assertEqual(rd.macro_columns(MACROS, "U-LOOP", get), {11}) # a macro that runs another
found = macros_found([it["pres"]["path"] for it in prop()["items"] if it["pres"]])
line = rd.eval_columns(RC, prop(), found, get, composition())
self.assertEqual((msgs(line), line.info), ([], "1 COLUMNS OK"))
line = rd.eval_columns(RC, prop(), found, get, composition(empty=(11,)))
self.assertEqual(msgs(line), ["COL 11 EMPTY"])
self.assertIn("Theme 10 White", line.faults[0][2])
self.assertIn("Holy Forever", line.faults[0][2])
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition(ignored=(11,)))), ["COL 11 EMPTY"])
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition(columns=10))), ["COL 11 MISSING"])
self.assertEqual(msgs(rd.eval_columns(RC, prop(), found, get, composition("Test"))), ["WRONG DECK"])
self.assertEqual(rd.eval_columns(RC, prop(), {"macros": {}, "used": {}}, get, composition()).info, "NO COLUMNS USED")
def test_the_macro_reader_runs(self):
compile(rd.MACRO_SCRIPT % json.dumps({"paths": ["/x.pro"]}), "remote", "exec")
def test_prop_bundle_this_week(self): def test_prop_bundle_this_week(self):
since = rd.week_start(WEDNESDAY) since = rd.week_start(WEDNESDAY)
@@ -302,7 +348,8 @@ class TestLook(unittest.TestCase):
def sources(self, **over): def sources(self, **over):
src = dict(pco=plan, prop=lambda p: prop(), ableton=lambda p: ableton(), src = dict(pco=plan, prop=lambda p: prop(), ableton=lambda p: ableton(),
lineup=lambda: lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]), lineup=lambda: lineup(["Jonah Perdue", "Krista Perdue", "Kevin Michel", "Anthony Hoyt", "Tim Hatch"]),
resolume=composition, prop_bundle=lambda: [["Message Templates.probundle", WEDNESDAY]]) resolume=composition, prop_bundle=lambda: [["Message Templates.probundle", WEDNESDAY]],
macros=macros_found, companion=lambda n: COMPANION.get(n, {}))
src.update(over) src.update(over)
return src return src
@@ -322,7 +369,7 @@ class TestLook(unittest.TestCase):
self.assertEqual(r["items"]["lineup"]["faults"], []) self.assertEqual(r["items"]["lineup"]["faults"], [])
r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources(pco=lambda: (_ for _ in ()).throw(rd.ReadyError("NO PCO LOGIN")))) r = rd.look(CONFIG, now=WEDNESDAY, sources=self.sources(pco=lambda: (_ for _ in ()).throw(rd.ReadyError("NO PCO LOGIN"))))
self.assertEqual(r["items"]["tracks"]["faults"], [["warn", "NO PCO LOGIN"]]) self.assertEqual(r["items"]["tracks"]["faults"], [["warn", "NO PCO LOGIN"]])
self.assertEqual(r["items"]["led_cross"]["faults"], []) # Resolume needs no plan self.assertEqual(r["items"]["led_cross"]["faults"], [["warn", "NO PCO LOGIN"]]) # no plan, no columns to look at
def test_a_campus_that_only_receives(self): def test_a_campus_that_only_receives(self):
cfg = json.loads(json.dumps(CONFIG)) cfg = json.loads(json.dumps(CONFIG))
@@ -381,7 +428,7 @@ class TestWatchdog(unittest.TestCase):
def test_one_hold_fixes_everything_that_can_be_fixed(self): def test_one_hold_fixes_everything_that_can_be_fixed(self):
look = rd.look(CONFIG, now=WEDNESDAY, sources=TestLook().sources( look = rd.look(CONFIG, now=WEDNESDAY, sources=TestLook().sources(
lineup=lambda: lineup(["DJ McLean"]), resolume=lambda: composition("Test", False))) lineup=lambda: lineup(["DJ McLean"]), resolume=lambda: composition("Test")))
self.snap.ready = look self.snap.ready = look
self.step(WEDNESDAY) self.step(WEDNESDAY)
plan = self.engine.fix_plan("ready", self.snap) plan = self.engine.fix_plan("ready", self.snap)
@@ -457,23 +504,20 @@ class TestFixes(unittest.TestCase):
rd.http_json = real rd.http_json = real
self.assertEqual(calls[:2], [("GET", "state"), ("POST", "sync")]) self.assertEqual(calls[:2], [("GET", "state"), ("POST", "sync")])
def test_cross_comes_back_with_the_clip_last_seen(self): def test_cross_hold_puts_resolume_back_on_its_deck(self):
calls = [] calls = []
real = rd.http_json real = rd.http_json
comp = composition("Test", False)
def fake(url, headers=None, method="GET", data=None, timeout=10): def fake(url, headers=None, method="GET", data=None, timeout=10):
calls.append((method, url.split("/api/v1/composition")[-1])) calls.append((method, url.split("/api/v1/composition")[-1]))
return comp return composition("Test")
rd.http_json = fake rd.http_json = fake
rd.MEMORY["led_clip"] = "Stained Glass 11"
try: try:
self.assertTrue(rd.run_action(CONFIG, "led_cross", sleep=lambda s: None)) self.assertTrue(rd.run_action(CONFIG, "led_cross", sleep=lambda s: None))
finally: finally:
rd.http_json = real rd.http_json = real
rd.MEMORY.clear()
self.assertIn(("POST", "/decks/1/select"), calls) self.assertIn(("POST", "/decks/1/select"), calls)
self.assertIn(("POST", "/layers/1/clips/2/connect"), calls) self.assertFalse(any("/clips/" in path for _, path in calls)) # never plays a clip by itself
class TestSpotifyWhileMuted(unittest.TestCase): class TestSpotifyWhileMuted(unittest.TestCase):