Generate the board from a script so looks, positions, and figure-8s stay in one place instead of being edited by hand in QLC+. Co-authored-by: Cursor <cursoragent@cursor.com>
833 lines
23 KiB
Python
833 lines
23 KiB
Python
#!/usr/bin/env python3
|
||
"""Generate shows/cabaret.qxw — grouped can RGB + per-mover position and RGB."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
OUT = ROOT / "shows" / "cabaret.qxw"
|
||
|
||
M1, M2 = 8, 9
|
||
|
||
# Can groups: 1-2 | 3-4-5-6 | 7-8
|
||
GROUP_L = [0, 1]
|
||
GROUP_C = [2, 3, 4, 5]
|
||
GROUP_R = [6, 7]
|
||
|
||
# Can 8ch: Dim=0, R=2, G=3, B=4
|
||
# Mover 43ch: Focus=7, Center Bright=8; RGB heads 1–7
|
||
MOVER_FOCUS = 7
|
||
MOVER_DIM = 8
|
||
MOVER_R = [10, 14, 18, 22, 26, 30, 34]
|
||
MOVER_G = [11, 15, 19, 23, 27, 31, 35]
|
||
MOVER_B = [12, 16, 20, 24, 28, 32, 36]
|
||
FOCUS_DEFAULT = 70
|
||
CANS = list(range(8))
|
||
|
||
PALETTE: dict[str, tuple[int, int, int]] = {
|
||
"Amber": (255, 90, 0),
|
||
"Pink": (255, 50, 160),
|
||
"HotBlue": (round(0.30 * 255), 0, 255), # 30% red, 100% blue
|
||
"Red": (255, 0, 0),
|
||
"Green": (0, 255, 0),
|
||
"Blue": (0, 0, 255),
|
||
"Orange": (255, 127, 0),
|
||
"Yellow": (255, 255, 0),
|
||
"Purple": (127, 0, 255),
|
||
}
|
||
|
||
CAN_ODD = [0, 2, 4, 6] # #1,3,5,7
|
||
CAN_EVEN = [1, 3, 5, 7] # #2,4,6,8
|
||
MOVER_SOLIDS = [
|
||
"Amber",
|
||
"Pink",
|
||
"Red",
|
||
"Green",
|
||
"Blue",
|
||
"Orange",
|
||
"Yellow",
|
||
"Purple",
|
||
]
|
||
|
||
# Pad display values. M2 (left) is hung 180°, so its pad pan is reversed:
|
||
# DMX pan = 255 - pad pan. M1 pad pan is DMX pan.
|
||
PAD_PAN = {M2: 181, M1: 80}
|
||
PAD_TILT = {M2: 145, M1: 161}
|
||
POSITION_FADE_MS = 2000
|
||
# name, M1 (pan, tilt), M2 (pan, tilt), button colour
|
||
# Same DMX pan = split (180° hang). Cross left was mirrored (108) = 180° out.
|
||
# Now = live pad aim (M2 pad 181/145 → DMX 74/145, M1 pad 80/161).
|
||
POSITIONS: list[tuple[str, tuple[int, int], tuple[int, int], str]] = [
|
||
("Now", (80, 161), (74, 145), "4278236360"),
|
||
("Here", (179, 0), (179, 0), "4282664004"),
|
||
("Down", (179, 36), (179, 36), "4284900966"),
|
||
("Mid", (164, 18), (144, 18), "4286611584"),
|
||
("Cross", (140, 26), (140, 26), "4281545523"),
|
||
("Out", (200, 22), (200, 22), "4279900698"),
|
||
]
|
||
POSITION_FUNC0 = 100
|
||
PADS_FUNC = POSITION_FUNC0 + len(POSITIONS)
|
||
PADS_BG = "4282800750" # #465A6E pad grey-blue
|
||
FIGURE8_FUNC = 110
|
||
FIGURE8_SIDE_FUNC = 111
|
||
FIGURE8_MS = 12000
|
||
FIGURE8_BG = "4282664004"
|
||
FIGURE8_SIDE_BG = "4286611584"
|
||
LOOK_PAIRS: list[tuple[str, str | None]] = [
|
||
("Amber", None),
|
||
("Pink", "Amber"),
|
||
("Pink", "HotBlue"),
|
||
("Red", "Green"),
|
||
("Blue", "Green"),
|
||
("Blue", "Red"),
|
||
("Orange", "Blue"),
|
||
("Yellow", "Purple"),
|
||
]
|
||
|
||
|
||
def color_label(name: str) -> str:
|
||
return "Blue" if name == "HotBlue" else name
|
||
|
||
|
||
def qlc_color(r: int, g: int, b: int) -> str:
|
||
return str((0xFF << 24) | (r << 16) | (g << 8) | b)
|
||
|
||
|
||
def mix_color(
|
||
a: tuple[int, int, int], b: tuple[int, int, int]
|
||
) -> tuple[int, int, int]:
|
||
return ((a[0] + b[0]) // 2, (a[1] + b[1]) // 2, (a[2] + b[2]) // 2)
|
||
|
||
RGB = [
|
||
("R", 2, MOVER_R, "4294901760", "Red"),
|
||
("G", 3, MOVER_G, "4278255360", "Green"),
|
||
("B", 4, MOVER_B, "4278190335", "Blue"),
|
||
]
|
||
|
||
|
||
def appearance(
|
||
fg: str = "Default",
|
||
bg: str = "Default",
|
||
frame: str = "Sunken",
|
||
) -> str:
|
||
return f""" <Appearance>
|
||
<FrameStyle>{frame}</FrameStyle>
|
||
<ForegroundColor>{fg}</ForegroundColor>
|
||
<BackgroundColor>{bg}</BackgroundColor>
|
||
<BackgroundImage>None</BackgroundImage>
|
||
<Font>Default</Font>
|
||
</Appearance>"""
|
||
|
||
|
||
def slider(
|
||
wid: int,
|
||
caption: str,
|
||
x: int,
|
||
y: int,
|
||
w: int,
|
||
h: int,
|
||
channels: list[tuple[int, int]],
|
||
*,
|
||
value: int = 0,
|
||
fg: str = "Default",
|
||
cng: str = "None",
|
||
) -> str:
|
||
ch_xml = "\n".join(
|
||
f' <Channel Fixture="{fid}">{ch}</Channel>' for fid, ch in channels
|
||
)
|
||
return f""" <Slider Caption="{caption}" ID="{wid}" WidgetStyle="Slider" InvertedAppearance="false">
|
||
<WindowState Visible="True" X="{x}" Y="{y}" Width="{w}" Height="{h}"/>
|
||
{appearance(fg=fg)}
|
||
<SliderMode ValueDisplayStyle="Exact" ClickAndGoType="{cng}" Monitor="true">Level</SliderMode>
|
||
<Reset/>
|
||
<Level LowLimit="0" HighLimit="255" Value="{value}">
|
||
{ch_xml}
|
||
</Level>
|
||
<Playback>
|
||
<Function>4294967295</Function>
|
||
</Playback>
|
||
</Slider>"""
|
||
|
||
|
||
def speed_dial(
|
||
wid: int,
|
||
caption: str,
|
||
x: int,
|
||
y: int,
|
||
w: int,
|
||
h: int,
|
||
func_ids: list[int],
|
||
*,
|
||
time_ms: int = FIGURE8_MS,
|
||
min_ms: int = 500,
|
||
max_ms: int = 30000,
|
||
) -> str:
|
||
fn_xml = "\n".join(
|
||
f' <Function FadeIn="0" FadeOut="0" Duration="6">{fid}</Function>'
|
||
for fid in func_ids
|
||
)
|
||
return f""" <SpeedDial Caption="{caption}" ID="{wid}">
|
||
<WindowState Visible="True" X="{x}" Y="{y}" Width="{w}" Height="{h}"/>
|
||
{appearance()}
|
||
<Visibility>2</Visibility>
|
||
<AbsoluteValue Minimum="{min_ms}" Maximum="{max_ms}"/>
|
||
<Time>{time_ms}</Time>
|
||
{fn_xml}
|
||
</SpeedDial>"""
|
||
|
||
|
||
def xypad(
|
||
wid: int,
|
||
caption: str,
|
||
fid: int,
|
||
x: int,
|
||
y: int,
|
||
w: int,
|
||
h: int,
|
||
*,
|
||
reverse_pan: bool = False,
|
||
reverse_tilt: bool = False,
|
||
pan: int = 128,
|
||
tilt: int = 128,
|
||
) -> str:
|
||
pan_rev = "True" if reverse_pan else "False"
|
||
tilt_rev = "True" if reverse_tilt else "False"
|
||
return f""" <XYPad Caption="{caption}" ID="{wid}" InvertedAppearance="0">
|
||
<WindowState Visible="True" X="{x}" Y="{y}" Width="{w}" Height="{h}"/>
|
||
{appearance()}
|
||
<Fixture ID="{fid}" Head="0">
|
||
<Axis ID="X" LowLimit="0" HighLimit="1" Reverse="{pan_rev}"/>
|
||
<Axis ID="Y" LowLimit="0" HighLimit="1" Reverse="{tilt_rev}"/>
|
||
</Fixture>
|
||
<Window hMin="0" hMax="255" vMin="0" vMax="255"/>
|
||
<Pan Position="{pan}"/>
|
||
<Tilt Position="{tilt}"/>
|
||
</XYPad>"""
|
||
|
||
|
||
def label_frame(wid: int, caption: str, x: int, y: int, w: int, h: int) -> str:
|
||
return f""" <Frame Caption="{caption}" ID="{wid}">
|
||
<Appearance>
|
||
<FrameStyle>Sunken</FrameStyle>
|
||
<ForegroundColor>Default</ForegroundColor>
|
||
<BackgroundColor>Default</BackgroundColor>
|
||
<BackgroundImage>None</BackgroundImage>
|
||
<Font>Default</Font>
|
||
</Appearance>
|
||
<WindowState Visible="True" X="{x}" Y="{y}" Width="{w}" Height="{h}"/>
|
||
<AllowChildren>True</AllowChildren>
|
||
<AllowResize>True</AllowResize>
|
||
<ShowHeader>True</ShowHeader>
|
||
<ShowEnableButton>False</ShowEnableButton>
|
||
<ShowIntensityButton>False</ShowIntensityButton>
|
||
<Mixing>False</Mixing>
|
||
</Frame>"""
|
||
|
||
|
||
def button(
|
||
wid: int,
|
||
caption: str,
|
||
func_id: int,
|
||
x: int,
|
||
y: int,
|
||
w: int,
|
||
h: int,
|
||
bg: str,
|
||
fg: str = "4294967295",
|
||
*,
|
||
indent: str = " ",
|
||
) -> str:
|
||
inner = indent + " "
|
||
return f"""{indent}<Button Caption="{caption}" ID="{wid}" Icon="">
|
||
{inner}<WindowState Visible="True" X="{x}" Y="{y}" Width="{w}" Height="{h}"/>
|
||
{inner}<Appearance>
|
||
{inner} <FrameStyle>None</FrameStyle>
|
||
{inner} <ForegroundColor>{fg}</ForegroundColor>
|
||
{inner} <BackgroundColor>{bg}</BackgroundColor>
|
||
{inner} <BackgroundImage>None</BackgroundImage>
|
||
{inner} <Font>Default</Font>
|
||
{inner}</Appearance>
|
||
{inner}<Function ID="{func_id}"/>
|
||
{inner}<Action>Toggle</Action>
|
||
{inner}<Intensity Adjust="False">100</Intensity>
|
||
{indent}</Button>"""
|
||
|
||
|
||
def solo_frame(wid: int, caption: str, x: int, y: int, w: int, h: int, children: str) -> str:
|
||
return f""" <SoloFrame Caption="{caption}" ID="{wid}">
|
||
<Appearance>
|
||
<FrameStyle>Sunken</FrameStyle>
|
||
<ForegroundColor>Default</ForegroundColor>
|
||
<BackgroundColor>Default</BackgroundColor>
|
||
<BackgroundImage>None</BackgroundImage>
|
||
<Font>Default</Font>
|
||
</Appearance>
|
||
<WindowState Visible="True" X="{x}" Y="{y}" Width="{w}" Height="{h}"/>
|
||
<AllowChildren>True</AllowChildren>
|
||
<AllowResize>True</AllowResize>
|
||
<ShowHeader>True</ShowHeader>
|
||
<ShowEnableButton>False</ShowEnableButton>
|
||
<Mixing>False</Mixing>
|
||
{children}
|
||
</SoloFrame>"""
|
||
|
||
|
||
def can_fixture_val(fid: int, r: int, g: int, b: int) -> str:
|
||
# Dimmer is owned by the Can Bright slider (HTP).
|
||
return (
|
||
f' <FixtureVal ID="{fid}">'
|
||
f"1,0,2,{r},3,{g},4,{b},5,0,6,0,7,0"
|
||
f"</FixtureVal>"
|
||
)
|
||
|
||
|
||
def mover_fixture_val(fid: int, r: int, g: int, b: int) -> str:
|
||
# Dimmer is owned by the Mover Bright slider (HTP).
|
||
parts: list[int] = []
|
||
for i in range(7):
|
||
base = 10 + i * 4
|
||
parts.extend([base, r, base + 1, g, base + 2, b, base + 3, 0])
|
||
return f' <FixtureVal ID="{fid}">{",".join(str(x) for x in parts)}</FixtureVal>'
|
||
|
||
|
||
def look_scene_xml(
|
||
func_id: int,
|
||
name: str,
|
||
can_colors: dict[int, tuple[int, int, int]] | None = None,
|
||
mover_colors: dict[int, tuple[int, int, int]] | None = None,
|
||
) -> str:
|
||
vals: list[str] = []
|
||
if can_colors:
|
||
vals += [can_fixture_val(fid, *rgb) for fid, rgb in can_colors.items()]
|
||
if mover_colors:
|
||
vals += [mover_fixture_val(fid, *rgb) for fid, rgb in mover_colors.items()]
|
||
return f""" <Function ID="{func_id}" Type="Scene" Name="{name}">
|
||
<Speed FadeIn="0" FadeOut="0" Duration="0"/>
|
||
{chr(10).join(vals)}
|
||
</Function>"""
|
||
|
||
|
||
def look_caption(kind: str, left_n: str, right_n: str | None) -> str:
|
||
ll, rl = color_label(left_n), color_label(right_n or left_n)
|
||
if right_n is None:
|
||
return ll
|
||
if kind == "cans":
|
||
return f"1-4 {ll} 5-8 {rl}"
|
||
return f"Odd {ll} Even {rl}"
|
||
|
||
|
||
def look_values(
|
||
kind: str, left: tuple[int, int, int], right: tuple[int, int, int]
|
||
) -> tuple[dict[int, tuple[int, int, int]] | None, dict[int, tuple[int, int, int]] | None]:
|
||
if kind == "cans":
|
||
return {**{i: left for i in range(4)}, **{i: right for i in range(4, 8)}}, None
|
||
if kind == "cans_oe":
|
||
return {**{i: left for i in CAN_ODD}, **{i: right for i in CAN_EVEN}}, None
|
||
raise ValueError(kind)
|
||
|
||
|
||
def build_looks() -> tuple[str, dict[str, list[tuple[str, int, str]]]]:
|
||
specs: dict[str, list[tuple[str, int, str]]] = {
|
||
"cans": [],
|
||
"movers": [],
|
||
"cans_oe": [],
|
||
}
|
||
scenes: list[str] = []
|
||
func_id = 0
|
||
for kind in ("cans", "cans_oe"):
|
||
for left_n, right_n in LOOK_PAIRS:
|
||
if kind == "cans_oe" and right_n is None:
|
||
continue
|
||
left = PALETTE[left_n]
|
||
right = PALETTE[right_n] if right_n else left
|
||
cans, movers = look_values(kind, left, right)
|
||
if right_n is None:
|
||
cans = {fid: left for fid in CANS}
|
||
movers = None
|
||
ll, rl = color_label(left_n), color_label(right_n or left_n)
|
||
name = f"{kind} {ll}" if right_n is None else f"{kind} {ll} {rl}"
|
||
scenes.append(look_scene_xml(func_id, name, cans, movers))
|
||
bg = qlc_color(*left) if right_n is None else qlc_color(*mix_color(left, right))
|
||
specs[kind].append((look_caption(kind, left_n, right_n), func_id, bg))
|
||
func_id += 1
|
||
for color_n in MOVER_SOLIDS:
|
||
rgb = PALETTE[color_n]
|
||
label = color_label(color_n)
|
||
scenes.append(
|
||
look_scene_xml(
|
||
func_id,
|
||
f"movers {label}",
|
||
mover_colors={M1: rgb, M2: rgb},
|
||
)
|
||
)
|
||
specs["movers"].append((label, func_id, qlc_color(*rgb)))
|
||
func_id += 1
|
||
return "\n".join(scenes), specs
|
||
|
||
|
||
def look_scenes_xml() -> str:
|
||
xml, _ = build_looks()
|
||
return xml
|
||
|
||
|
||
def position_scene_xml() -> str:
|
||
blocks = [
|
||
f""" <Function ID="{PADS_FUNC}" Type="Scene" Name="Mover Pads">
|
||
<Speed FadeIn="0" FadeOut="0" Duration="0"/>
|
||
</Function>"""
|
||
]
|
||
for i, (name, m1, m2, _bg) in enumerate(POSITIONS):
|
||
poses = {M1: m1, M2: m2}
|
||
vals = []
|
||
for fid in (M1, M2):
|
||
pan, tilt = poses[fid]
|
||
vals.append(
|
||
f' <FixtureVal ID="{fid}">0,{pan},1,0,2,{tilt},3,0</FixtureVal>'
|
||
)
|
||
blocks.append(
|
||
f""" <Function ID="{POSITION_FUNC0 + i}" Type="Scene" Name="Mover {name}">
|
||
<Speed FadeIn="{POSITION_FADE_MS}" FadeOut="0" Duration="{POSITION_FADE_MS}"/>
|
||
{chr(10).join(vals)}
|
||
</Function>"""
|
||
)
|
||
return "\n".join(blocks)
|
||
|
||
|
||
def position_button_specs() -> list[tuple[str, int, str]]:
|
||
return (
|
||
[("Pads", PADS_FUNC, PADS_BG)]
|
||
+ [
|
||
(name, POSITION_FUNC0 + i, bg)
|
||
for i, (name, _m1, _m2, bg) in enumerate(POSITIONS)
|
||
]
|
||
+ [("Fig 8", FIGURE8_FUNC, FIGURE8_BG), ("Side 8", FIGURE8_SIDE_FUNC, FIGURE8_SIDE_BG)]
|
||
)
|
||
|
||
|
||
def figure8_bounds() -> tuple[int, int, int, int]:
|
||
"""Pan/tilt centre and amplitude covering Here–Out (not the live Now aim)."""
|
||
pans: list[int] = []
|
||
tilts: list[int] = []
|
||
for name, m1, m2, _bg in POSITIONS:
|
||
if name == "Now":
|
||
continue
|
||
for pan, tilt in (m1, m2):
|
||
pans.append(pan)
|
||
tilts.append(tilt)
|
||
p0, p1 = min(pans), max(pans)
|
||
t0, t1 = min(tilts), max(tilts)
|
||
return (p0 + p1) // 2, (t0 + t1) // 2, (p1 - p0) // 2, (t1 - t0) // 2
|
||
|
||
|
||
def figure8_efx_block(
|
||
func_id: int,
|
||
name: str,
|
||
width: int,
|
||
height: int,
|
||
rotation: int,
|
||
pan_c: int,
|
||
tilt_c: int,
|
||
) -> str:
|
||
fixtures = "\n".join(
|
||
f""" <Fixture>
|
||
<ID>{fid}</ID>
|
||
<Direction>Forward</Direction>
|
||
<Intensity>255</Intensity>
|
||
</Fixture>"""
|
||
for fid in (M1, M2)
|
||
)
|
||
return f""" <Function ID="{func_id}" Type="EFX" Name="{name}">
|
||
{fixtures}
|
||
<PropagationMode>Parallel</PropagationMode>
|
||
<Speed Duration="{FIGURE8_MS}" FadeOut="0" FadeIn="0"/>
|
||
<Direction>Forward</Direction>
|
||
<RunOrder>Loop</RunOrder>
|
||
<Algorithm>Eight</Algorithm>
|
||
<Width>{width}</Width>
|
||
<Height>{height}</Height>
|
||
<Rotation>{rotation}</Rotation>
|
||
<Axis Name="X">
|
||
<Offset>{pan_c}</Offset>
|
||
<Frequency>1</Frequency>
|
||
<Phase>0</Phase>
|
||
</Axis>
|
||
<Axis Name="Y">
|
||
<Offset>{tilt_c}</Offset>
|
||
<Frequency>1</Frequency>
|
||
<Phase>0</Phase>
|
||
</Axis>
|
||
</Function>"""
|
||
|
||
|
||
def figure8_efx_xml() -> str:
|
||
pan_c, tilt_c, pan_amp, tilt_amp = figure8_bounds()
|
||
# Sideways ∞ is rotation 0. 90° is a vertical 8 (tilt 0 → 36 down);
|
||
# width/height are swapped so both still fill the Here–Out box.
|
||
return "\n".join(
|
||
[
|
||
figure8_efx_block(
|
||
FIGURE8_FUNC, "Figure of 8", tilt_amp, pan_amp, 90, pan_c, tilt_c
|
||
),
|
||
figure8_efx_block(
|
||
FIGURE8_SIDE_FUNC,
|
||
"Figure of 8 Side",
|
||
pan_amp,
|
||
tilt_amp,
|
||
0,
|
||
pan_c,
|
||
tilt_c,
|
||
),
|
||
]
|
||
)
|
||
|
||
|
||
def fixture_xml() -> str:
|
||
parts = []
|
||
for i in range(8):
|
||
parts.append(
|
||
f""" <Fixture>
|
||
<Manufacturer>beamZ</Manufacturer>
|
||
<Model>BCC145</Model>
|
||
<Mode>8 Channel</Mode>
|
||
<ID>{i}</ID>
|
||
<Name>BCC145 #{i + 1}</Name>
|
||
<Universe>0</Universe>
|
||
<Address>{87 + i * 8}</Address>
|
||
<Channels>8</Channels>
|
||
</Fixture>"""
|
||
)
|
||
for i, addr in enumerate((0, 44)):
|
||
parts.append(
|
||
f""" <Fixture>
|
||
<Manufacturer>Small Eye 7</Manufacturer>
|
||
<Model>Mover</Model>
|
||
<Mode>43 Channel</Mode>
|
||
<ID>{8 + i}</ID>
|
||
<Name>Small Eye 7 #{i + 1}</Name>
|
||
<Universe>0</Universe>
|
||
<Address>{addr}</Address>
|
||
<Channels>43</Channels>
|
||
</Fixture>"""
|
||
)
|
||
return "\n".join(parts)
|
||
|
||
|
||
def fixture_groups_xml() -> str:
|
||
def heads(fids: list[int]) -> str:
|
||
return "\n".join(
|
||
f' <Head X="{i}" Y="0" Fixture="{fid}">0</Head>'
|
||
for i, fid in enumerate(fids)
|
||
)
|
||
|
||
return f""" <FixtureGroup ID="0">
|
||
<Name>Cans 1-2</Name>
|
||
<Size X="2" Y="1"/>
|
||
{heads(GROUP_L)}
|
||
</FixtureGroup>
|
||
<FixtureGroup ID="1">
|
||
<Name>Cans 3-6</Name>
|
||
<Size X="4" Y="1"/>
|
||
{heads(GROUP_C)}
|
||
</FixtureGroup>
|
||
<FixtureGroup ID="2">
|
||
<Name>Cans 7-8</Name>
|
||
<Size X="2" Y="1"/>
|
||
{heads(GROUP_R)}
|
||
</FixtureGroup>
|
||
<FixtureGroup ID="3">
|
||
<Name>Small Eye Movers</Name>
|
||
<Size X="2" Y="1"/>
|
||
<Head X="0" Y="0" Fixture="8">0</Head>
|
||
<Head X="1" Y="0" Fixture="9">0</Head>
|
||
</FixtureGroup>"""
|
||
|
||
|
||
def main() -> None:
|
||
pad_w, pad_h = 240, 240
|
||
sl_w, sl_h = 44, 240
|
||
gap = 8
|
||
block_gap = 28
|
||
|
||
widgets: list[str] = []
|
||
wid = 0
|
||
max_x = 0
|
||
max_y = 0
|
||
|
||
def place_slider(caption, x, y, w, h, channels, value=0, fg="Default", cng="None"):
|
||
nonlocal wid, max_x, max_y
|
||
widgets.append(
|
||
slider(wid, caption, x, y, w, h, channels, value=value, fg=fg, cng=cng)
|
||
)
|
||
wid += 1
|
||
max_x = max(max_x, x + w)
|
||
max_y = max(max_y, y + h)
|
||
|
||
def place_rgb_block(prefix: str, x: int, y: int, dim_chs, rgb_chs_for):
|
||
"""Dim + R G B. rgb_chs_for(name, can_ch, mover_chs) -> channel list."""
|
||
place_slider(
|
||
f"{prefix} Dim", x, y, sl_w, sl_h, dim_chs, 0, "4294967295"
|
||
)
|
||
x += sl_w + 2
|
||
for name, can_ch, mover_chs, fg, cng in RGB:
|
||
place_slider(
|
||
f"{prefix} {name}",
|
||
x,
|
||
y,
|
||
sl_w,
|
||
sl_h,
|
||
rgb_chs_for(name, can_ch, mover_chs),
|
||
fg=fg,
|
||
cng=cng,
|
||
)
|
||
x += sl_w + 2
|
||
return x
|
||
|
||
def place_mover(prefix: str, fid: int, x: int, y: int, *, reverse_pan: bool = False) -> int:
|
||
nonlocal wid, max_y
|
||
widgets.append(
|
||
xypad(
|
||
wid,
|
||
f"{prefix} Position",
|
||
fid,
|
||
x,
|
||
y,
|
||
pad_w,
|
||
pad_h,
|
||
reverse_pan=reverse_pan,
|
||
pan=PAD_PAN[fid],
|
||
tilt=PAD_TILT[fid],
|
||
)
|
||
)
|
||
wid += 1
|
||
max_y = max(max_y, y + pad_h)
|
||
return x + pad_w + gap
|
||
|
||
def place_looks(
|
||
title: str,
|
||
looks: list[tuple[str, int, str]],
|
||
x: int,
|
||
y: int,
|
||
*,
|
||
cols: int = 2,
|
||
btn_w: int = 156,
|
||
) -> int:
|
||
nonlocal wid, max_x, max_y
|
||
header_h = 28
|
||
pad = 4
|
||
gap_x, gap_y = 6, 6
|
||
n = len(looks)
|
||
cols = max(1, min(cols, n))
|
||
rows = max(1, (n + cols - 1) // cols)
|
||
frame_w = pad * 2 + cols * btn_w + (cols - 1) * gap_x
|
||
frame_h = header_h + sl_h
|
||
inner_h = sl_h - pad
|
||
btn_h = (inner_h - gap_y * (rows - 1)) // rows
|
||
look_btns = []
|
||
frame_id = wid
|
||
wid += 1
|
||
for i, (caption, func_id, bg) in enumerate(looks):
|
||
col, row = i % cols, i // cols
|
||
look_btns.append(
|
||
button(
|
||
wid,
|
||
caption,
|
||
func_id,
|
||
pad + col * (btn_w + gap_x),
|
||
header_h + pad + row * (btn_h + gap_y),
|
||
btn_w,
|
||
btn_h,
|
||
bg,
|
||
indent=" ",
|
||
)
|
||
)
|
||
wid += 1
|
||
widgets.append(
|
||
solo_frame(
|
||
frame_id,
|
||
title,
|
||
x,
|
||
y - header_h,
|
||
frame_w,
|
||
frame_h,
|
||
"\n".join(look_btns),
|
||
)
|
||
)
|
||
max_x = max(max_x, x + frame_w)
|
||
max_y = max(max_y, y - header_h + frame_h)
|
||
return x + frame_w
|
||
|
||
_, look_specs = build_looks()
|
||
|
||
y0 = 28
|
||
x = 10
|
||
# M2 on the left, M1 on the right. Left unit is hung opposite, so pan is reversed.
|
||
x = place_mover("M2", M2, x, y0, reverse_pan=True)
|
||
x += block_gap
|
||
x = place_mover("M1", M1, x, y0)
|
||
x += block_gap
|
||
place_slider(
|
||
"M Bright",
|
||
x,
|
||
y0,
|
||
sl_w,
|
||
sl_h,
|
||
[(M1, MOVER_DIM), (M2, MOVER_DIM)],
|
||
255,
|
||
"4294967295",
|
||
)
|
||
x += sl_w + 2
|
||
place_slider(
|
||
"M Focus",
|
||
x,
|
||
y0,
|
||
sl_w,
|
||
sl_h,
|
||
[(M1, MOVER_FOCUS), (M2, MOVER_FOCUS)],
|
||
FOCUS_DEFAULT,
|
||
"4294902015",
|
||
)
|
||
x += sl_w + 2
|
||
widgets.append(
|
||
speed_dial(
|
||
wid,
|
||
"Speed",
|
||
x,
|
||
y0,
|
||
sl_w,
|
||
sl_h,
|
||
[FIGURE8_FUNC, FIGURE8_SIDE_FUNC],
|
||
)
|
||
)
|
||
wid += 1
|
||
max_x = max(max_x, x + sl_w)
|
||
max_y = max(max_y, y0 + sl_h)
|
||
x += sl_w + 2
|
||
for name, _can_ch, mover_chs, fg, cng in RGB:
|
||
place_slider(
|
||
f"M {name}",
|
||
x,
|
||
y0,
|
||
sl_w,
|
||
sl_h,
|
||
[(fid, c) for fid in (M1, M2) for c in mover_chs],
|
||
fg=fg,
|
||
cng=cng,
|
||
)
|
||
x += sl_w + 2
|
||
x += block_gap - 2
|
||
x = place_looks(
|
||
"Position",
|
||
position_button_specs(),
|
||
x,
|
||
y0,
|
||
cols=2,
|
||
btn_w=72,
|
||
)
|
||
x += block_gap
|
||
x = place_looks("Movers", look_specs["movers"], x, y0)
|
||
|
||
y1 = y0 + pad_h + 40
|
||
x = 10
|
||
groups = [
|
||
("1-2", GROUP_L),
|
||
("3-6", GROUP_C),
|
||
("7-8", GROUP_R),
|
||
]
|
||
for label, fids in groups:
|
||
block_w = 4 * sl_w + 3 * 2
|
||
widgets.append(label_frame(wid, f"Cans {label}", x, y1 - 28, block_w, 24))
|
||
wid += 1
|
||
x = place_rgb_block(
|
||
label,
|
||
x,
|
||
y1,
|
||
[(f, 0) for f in fids],
|
||
lambda _n, can_ch, _m, fids=fids: [(f, can_ch) for f in fids],
|
||
)
|
||
x += block_gap
|
||
|
||
place_slider(
|
||
"C Bright",
|
||
x,
|
||
y1,
|
||
sl_w,
|
||
sl_h,
|
||
[(f, 0) for f in CANS],
|
||
255,
|
||
"4294967295",
|
||
)
|
||
x += sl_w + 2
|
||
place_slider(
|
||
"C Color",
|
||
x,
|
||
y1,
|
||
sl_w,
|
||
sl_h,
|
||
[(f, ch) for f in CANS for ch in (2, 3, 4)],
|
||
0,
|
||
"4294967295",
|
||
"RGB",
|
||
)
|
||
x += sl_w + block_gap
|
||
x = place_looks("Cans", look_specs["cans"], x, y1)
|
||
x += block_gap
|
||
place_looks("Cans Odd/Even", look_specs["cans_oe"], x, y1)
|
||
|
||
total_w = max_x + 16
|
||
total_h = max_y + 16
|
||
|
||
qxw = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||
<!DOCTYPE Workspace>
|
||
<Workspace xmlns="http://www.qlcplus.org/Workspace" CurrentWindow="VirtualConsole">
|
||
<Creator>
|
||
<Name>Q Light Controller Plus</Name>
|
||
<Version>4.12.7</Version>
|
||
<Author>Jimmy</Author>
|
||
</Creator>
|
||
<Engine>
|
||
<InputOutputMap>
|
||
<Universe Name="Universe 1" ID="0">
|
||
<Output Plugin="ArtNet" UID="192.168.1.92" Line="7">
|
||
<PluginParameters outputIP="192.168.1.228" outputUni="0"/>
|
||
</Output>
|
||
</Universe>
|
||
<Universe Name="Universe 2" ID="1"/>
|
||
<Universe Name="Universe 3" ID="2"/>
|
||
<Universe Name="Universe 4" ID="3"/>
|
||
</InputOutputMap>
|
||
{fixture_xml()}
|
||
{fixture_groups_xml()}
|
||
{look_scenes_xml()}
|
||
{position_scene_xml()}
|
||
{figure8_efx_xml()}
|
||
</Engine>
|
||
<VirtualConsole>
|
||
<Frame Caption="">
|
||
<Appearance>
|
||
<FrameStyle>None</FrameStyle>
|
||
<ForegroundColor>Default</ForegroundColor>
|
||
<BackgroundColor>Default</BackgroundColor>
|
||
<BackgroundImage>None</BackgroundImage>
|
||
<Font>Default</Font>
|
||
</Appearance>
|
||
{"\n".join(widgets)}
|
||
</Frame>
|
||
<Properties>
|
||
<Size Width="{total_w}" Height="{total_h}"/>
|
||
<GrandMaster ChannelMode="Intensity" ValueMode="Reduce" SliderMode="Normal"/>
|
||
</Properties>
|
||
</VirtualConsole>
|
||
<SimpleDesk>
|
||
<Engine/>
|
||
</SimpleDesk>
|
||
</Workspace>
|
||
"""
|
||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||
OUT.write_text(qxw)
|
||
print(f"Wrote {OUT} ({total_w}x{total_h}, {wid} widgets)")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|