Files
qlcplus/scripts/make_cabaret_vc.py
T
jimmyandCursor 8f08d44188 Split cabaret figure-8s onto the floor and the wall, and make Speed a fader.
Floor 8 stays on the live ground aim; Wall 8 uses the Here–Out box. Pan/tilt speed is a slider so it matches Bright and Focus.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 00:47:18 +12:00

838 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: Pan/Tilt Speed=4, Focus=7, Center Bright=8; RGB heads 1–7
MOVER_SPEED = 4
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_FLOOR_FUNC = 110
FIGURE8_WALL_FUNC = 111
FIGURE8_MS = 12000
FIGURE8_FLOOR_BG = "4282664004"
FIGURE8_WALL_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",
inverted: bool = False,
) -> str:
ch_xml = "\n".join(
f' <Channel Fixture="{fid}">{ch}</Channel>' for fid, ch in channels
)
inv = "true" if inverted else "false"
return f""" <Slider Caption="{caption}" ID="{wid}" WidgetStyle="Slider" InvertedAppearance="{inv}">
<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 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)
]
+ [
("Floor 8", FIGURE8_FLOOR_FUNC, FIGURE8_FLOOR_BG),
("Wall 8", FIGURE8_WALL_FUNC, FIGURE8_WALL_BG),
]
)
def figure8_floor_center() -> tuple[int, int]:
"""Now is the live floor aim; average both heads so the 8 stays on the ground."""
now = next((m1, m2) for name, m1, m2, _bg in POSITIONS if name == "Now")
pans = [now[0][0], now[1][0]]
tilts = [now[0][1], now[1][1]]
return (pans[0] + pans[1]) // 2, (tilts[0] + tilts[1]) // 2
def figure8_wall_bounds() -> tuple[int, int, int, int]:
"""Pan/tilt centre and amplitude covering Here–Out (the wall looks)."""
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:
floor_pan, floor_tilt = figure8_floor_center()
wall_pan, wall_tilt, wall_pan_amp, wall_tilt_amp = figure8_wall_bounds()
# Floor 8: around Now, tilt stays on the ground. Rotation 90 = upright 8.
# Wall 8: Here–Out box (tilt 0–36). Rotation 0 = 8 across the wall.
return "\n".join(
[
figure8_efx_block(
FIGURE8_FLOOR_FUNC,
"Figure of 8 Floor",
16,
18,
90,
floor_pan,
floor_tilt,
),
figure8_efx_block(
FIGURE8_WALL_FUNC,
"Figure of 8 Walls",
wall_pan_amp,
wall_tilt_amp,
0,
wall_pan,
wall_tilt,
),
]
)
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", inverted=False
):
nonlocal wid, max_x, max_y
widgets.append(
slider(
wid,
caption,
x,
y,
w,
h,
channels,
value=value,
fg=fg,
cng=cng,
inverted=inverted,
)
)
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
place_slider(
"Speed",
x,
y0,
sl_w,
sl_h,
[(M1, MOVER_SPEED), (M2, MOVER_SPEED)],
0,
"4294967295",
inverted=True,
)
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()