поправлены способ передачи таймеров в vMix

This commit is contained in:
2026-08-20 15:33:03 +03:00
parent 8790a1e113
commit cd9476d167
6 changed files with 420 additions and 117 deletions

View File

@@ -238,6 +238,9 @@ class VmixAgentHub:
self._live: dict[str, LiveAgent] = {} self._live: dict[str, LiveAgent] = {}
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
self._pending_commands: dict[str, tuple[str, asyncio.Future[dict[str, Any]]]] = {} self._pending_commands: dict[str, tuple[str, asyncio.Future[dict[str, Any]]]] = {}
# BUILD90: one FIFO lock per Agent. Shortcut batches, Mapping refreshes and
# timer control commands can no longer interleave on the same vMix instance.
self._vmix_send_locks: dict[str, asyncio.Lock] = {}
self.mapping_data = MappingDataService(database, settings=settings) self.mapping_data = MappingDataService(database, settings=settings)
self._auto_refresh_task: asyncio.Task[None] | None = None self._auto_refresh_task: asyncio.Task[None] | None = None
self._auto_refresh_next: dict[tuple[str, str], float] = {} self._auto_refresh_next: dict[tuple[str, str], float] = {}
@@ -663,6 +666,13 @@ class VmixAgentHub:
self._live.pop(device_id, None) self._live.pop(device_id, None)
return False return False
def _device_vmix_send_lock(self, device_id: str) -> asyncio.Lock:
lock = self._vmix_send_locks.get(device_id)
if lock is None:
lock = asyncio.Lock()
self._vmix_send_locks[device_id] = lock
return lock
async def receive_command_ack(self, device_id: str, message: dict[str, Any]) -> None: async def receive_command_ack(self, device_id: str, message: dict[str, Any]) -> None:
request_id = str(message.get("request_id") or "").strip() request_id = str(message.get("request_id") or "").strip()
if not request_id: if not request_id:
@@ -677,7 +687,7 @@ class VmixAgentHub:
payload["device_id"] = device_id payload["device_id"] = device_id
future.set_result(payload) future.set_result(payload)
async def send_vmix_command( async def _send_vmix_command_unlocked(
self, self,
device_id: str, device_id: str,
*, *,
@@ -717,7 +727,21 @@ class VmixAgentHub:
if not pending_future.done(): if not pending_future.done():
pending_future.cancel() pending_future.cancel()
async def send_vmix_batch( async def send_vmix_command(
self,
device_id: str,
*,
assignment_id: str,
match_id: str,
command: dict[str, Any],
timeout: float = 5.0,
) -> dict[str, Any]:
async with self._device_vmix_send_lock(device_id):
return await self._send_vmix_command_unlocked(
device_id, assignment_id=assignment_id, match_id=match_id, command=command, timeout=timeout
)
async def _send_vmix_batch_unlocked(
self, self,
device_id: str, device_id: str,
*, *,
@@ -757,6 +781,20 @@ class VmixAgentHub:
if not pending_future.done(): if not pending_future.done():
pending_future.cancel() pending_future.cancel()
async def send_vmix_batch(
self,
device_id: str,
*,
assignment_id: str,
match_id: str,
commands: list[dict[str, Any]],
timeout: float = 8.0,
) -> dict[str, Any]:
async with self._device_vmix_send_lock(device_id):
return await self._send_vmix_batch_unlocked(
device_id, assignment_id=assignment_id, match_id=match_id, commands=commands, timeout=timeout
)
async def test_set_text( async def test_set_text(
self, self,
device_id: str, device_id: str,
@@ -1286,12 +1324,13 @@ class VmixAgentHub:
raise HTTPException(status_code=404, detail="Agent не найден") raise HTTPException(status_code=404, detail="Agent не найден")
assignment_id = str(device.current_assignment_key or "") assignment_id = str(device.current_assignment_key or "")
match_id = str(device.current_match_external_id or "") match_id = str(device.current_match_external_id or "")
agent_version = str(device.agent_version or "")
if not assignment_id or not match_id: if not assignment_id or not match_id:
raise HTTPException(status_code=409, detail="Agent не привязан к текущему матчу") raise HTTPException(status_code=409, detail="Agent не привязан к текущему матчу")
if operator_game_id and match_id != operator_game_id: if operator_game_id and match_id != operator_game_id:
raise HTTPException(status_code=409, detail="Agent назначен на другой матч") raise HTTPException(status_code=409, detail="Agent назначен на другой матч")
results: list[dict[str, Any]] = [] prepared_commands: list[dict[str, Any]] = []
for index, raw in enumerate(commands): for index, raw in enumerate(commands):
function = str(raw.get("Function") or raw.get("function") or "").strip() function = str(raw.get("Function") or raw.get("function") or "").strip()
if not function: if not function:
@@ -1303,36 +1342,62 @@ class VmixAgentHub:
if str(key).lower() != "value" and str(value) == "": if str(key).lower() != "value" and str(value) == "":
continue continue
command[str(key)] = value command[str(key)] = value
ack = await self.send_vmix_command( prepared_commands.append(command)
target_device_id,
assignment_id=assignment_id, results: list[dict[str, Any]] = []
match_id=match_id, transport = "batch" if self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 else "legacy"
command=command, async with self._device_vmix_send_lock(target_device_id):
timeout=timeout, if transport == "batch":
) ack = await self._send_vmix_batch_unlocked(
item = { target_device_id,
"index": index, assignment_id=assignment_id,
"function": function, match_id=match_id,
"ok": bool(ack.get("ok")), commands=prepared_commands,
"reason": str(ack.get("reason") or ack.get("error") or ""), timeout=max(4.0, min(8.0, timeout + 2.0)),
}
results.append(item)
if not item["ok"]:
raise HTTPException(
status_code=502,
detail={
"message": f"vMix command failed: {function}",
"index": index,
"results": results,
},
) )
self._track_runtime_overlay_command( ack_results = ack.get("results") if isinstance(ack.get("results"), list) else []
target_device_id, for index, command in enumerate(prepared_commands):
command, function = str(command.get("Function") or "")
sequence_id=sequence_id, item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {}
sequence_name=sequence_name, ok = bool(item_ack.get("ok")) if item_ack else bool(ack.get("ok"))
button_id=button_id, item = {
) "index": index,
"function": function,
"ok": ok,
"reason": str(item_ack.get("reason") or item_ack.get("error") or ("" if ok else ack.get("reason") or ack.get("error") or "vmix_batch_error")),
}
results.append(item)
if ok:
self._track_runtime_overlay_command(
target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id
)
if not all(item["ok"] for item in results):
failed = next(item for item in results if not item["ok"])
raise HTTPException(
status_code=502,
detail={"message": f"vMix command failed: {failed['function']}", "index": failed["index"], "results": results},
)
else:
for index, command in enumerate(prepared_commands):
function = str(command.get("Function") or "")
ack = await self._send_vmix_command_unlocked(
target_device_id, assignment_id=assignment_id, match_id=match_id, command=command, timeout=timeout
)
item = {
"index": index,
"function": function,
"ok": bool(ack.get("ok")),
"reason": str(ack.get("reason") or ack.get("error") or ""),
}
results.append(item)
if not item["ok"]:
raise HTTPException(
status_code=502,
detail={"message": f"vMix command failed: {function}", "index": index, "results": results},
)
self._track_runtime_overlay_command(
target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id
)
return { return {
"ok": True, "ok": True,
"device_id": target_device_id, "device_id": target_device_id,
@@ -1341,6 +1406,7 @@ class VmixAgentHub:
"session_token": session_token, "session_token": session_token,
"applied": len(results), "applied": len(results),
"results": results, "results": results,
"transport": transport,
"overlay_state": self._runtime_overlay_payload(target_device_id), "overlay_state": self._runtime_overlay_payload(target_device_id),
} }

View File

@@ -688,7 +688,7 @@ def test_runtime_vmix_sequence_preserves_command_order(tmp_path: Path) -> None:
"device_secret": "s" * 40, "device_secret": "s" * 40,
"device_name": "Sequence GFX", "device_name": "Sequence GFX",
"hostname": "SEQUENCE-PC", "hostname": "SEQUENCE-PC",
"agent_version": "1.4.0", "agent_version": "1.3.0",
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"}, "vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
}, },
) )
@@ -744,6 +744,65 @@ def test_runtime_vmix_sequence_preserves_command_order(tmp_path: Path) -> None:
result = await task result = await task
assert result["ok"] is True assert result["ok"] is True
assert result["applied"] == 2 assert result["applied"] == 2
assert result["transport"] == "legacy"
assert [item["function"] for item in result["results"]] == ["SetCountdown", "StartCountdown"] assert [item["function"] for item in result["results"]] == ["SetCountdown", "StartCountdown"]
asyncio.run(scenario()) asyncio.run(scenario())
def test_build90_runtime_shortcut_sequence_uses_one_batch_on_modern_agent(tmp_path: Path) -> None:
database = LocalTestDatabase(tmp_path / "agent-runtime-batch.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
ws = FakeWebSocket()
user = HockeyUser(id="92", login="operator92", display_name="operator92")
async def scenario() -> None:
await hub.register(
ws, # type: ignore[arg-type]
{
"device_id": "GFX-PC-RUNTIME-BATCH",
"device_secret": "r" * 40,
"device_name": "Runtime Batch GFX",
"hostname": "RUNTIME-BATCH-PC",
"agent_version": "1.4.0",
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
},
)
await hub.pair_device("GFX-PC-RUNTIME-BATCH", user)
assigned = await hub.assign_match(
wfl_user_id=user.id, tournament_external_id="1437", game_external_id="902919",
)
assert assigned is not None
task = asyncio.create_task(hub.run_vmix_sequence_for_user(
user,
[
{"Function": "SetCountdown", "Input": "53", "SelectedName": "Clock.Text", "Value": "00:18:42"},
{"Function": "StartCountdown", "Input": "53", "SelectedName": "Clock.Text"},
{"Function": "OverlayInput2In", "Input": "53"},
],
))
batch = None
for _ in range(50):
await asyncio.sleep(0)
batch = next((item for item in reversed(ws.sent) if item.get("type") == "vmix.batch"), None)
if batch is not None:
break
assert batch is not None
assert [item["Function"] for item in batch["commands"]] == ["SetCountdown", "StartCountdown", "OverlayInput2In"]
assert not [item for item in ws.sent if item.get("type") == "vmix.command"]
await hub.receive_command_ack(
"GFX-PC-RUNTIME-BATCH",
{
"type": "command.batch.ack", "request_id": batch["request_id"], "ok": True,
"results": [{"ok": True}, {"ok": True}, {"ok": True}],
},
)
result = await task
assert result["ok"] is True
assert result["transport"] == "batch"
assert result["applied"] == 3
assert result["overlay_state"]["overlays"]["2"]["input"] == "53"
asyncio.run(scenario())

View File

@@ -88,4 +88,6 @@ def test_rebalance_sends_old_plate_out_before_new_plate_in():
assert 'const outCommands = [];' in block assert 'const outCommands = [];' in block
assert 'const setCommands = [];' in block assert 'const setCommands = [];' in block
assert 'const inCommands = [];' in block assert 'const inCommands = [];' in block
assert 'const commands = [...outCommands, ...setCommands, ...inCommands];' in block assert 'const stopCommands = [];' in block
assert 'const runCommands = [];' in block
assert 'const commands = [...outCommands, ...stopCommands, ...setCommands, ...runCommands, ...inCommands];' in block

View File

@@ -104,9 +104,10 @@ def test_shortcut_editor_layout_fixes_add_step_buttons_and_fullscreen_modal() ->
assert "height: calc(100vh - 20px);" in css assert "height: calc(100vh - 20px);" in css
def test_hockey_timer_shortcut_supports_space_toggle_pause_and_text_mirror() -> None: def test_hockey_timer_shortcut_supports_native_countdown_and_legacy_text_mirror() -> None:
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8") app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
assert 'Function: "SuspendCountdown"' in app_js assert 'Function: "PauseCountdown"' in app_js
assert 'Function: "StopCountdown"' in app_js
assert 'hockey_timer_command' in app_js assert 'hockey_timer_command' in app_js
assert 'game_vmix_mode' in app_js assert 'game_vmix_mode' in app_js
assert 'game_vmix_selected_name' in app_js assert 'game_vmix_selected_name' in app_js
@@ -196,3 +197,61 @@ def test_overlay_group_repeat_uses_transition_out_not_all_off():
assert 'Function: "OverlayInputAllOff"' not in js assert 'Function: "OverlayInputAllOff"' not in js
for layer in range(1, 5): for layer in range(1, 5):
assert f'Function: "OverlayInput{layer}Out"' in js assert f'Function: "OverlayInput{layer}Out"' in js
def test_build90_migrates_old_text_timer_transport_to_native_countdown(tmp_path: Path) -> None:
manager = UIBuilderManager(tmp_path, filename="ui.json")
saved = manager.save({
"version": 21,
"project_name": "Hockey",
"data_source": "hockey",
"canvas": {"width": 1440, "height": 900},
"tabs": [{"id": "main", "label": "Main"}],
"components": [],
"triggers": [],
"shortcut_sequences": [{
"id": "timers", "name": "Timers", "combo": "Space",
"steps": [{
"id": "sync", "type": "hockey_vmix_timers_start",
"game_vmix_mode": "text", "penalty_vmix_mode": "text",
}],
}],
})
step = saved["shortcut_sequences"][0]["steps"][0]
assert saved["version"] == 22
assert step["game_vmix_mode"] == "countdown"
assert step["penalty_vmix_mode"] == "countdown"
def test_build90_keeps_explicit_legacy_text_mode_after_v22(tmp_path: Path) -> None:
manager = UIBuilderManager(tmp_path, filename="ui.json")
saved = manager.save({
"version": 22,
"project_name": "Hockey",
"data_source": "hockey",
"canvas": {"width": 1440, "height": 900},
"tabs": [{"id": "main", "label": "Main"}],
"components": [], "triggers": [],
"shortcut_sequences": [{
"id": "timers", "name": "Timers", "combo": "Space",
"steps": [{
"id": "sync", "type": "hockey_vmix_timers_start",
"game_vmix_mode": "text", "penalty_vmix_mode": "text",
}],
}],
})
step = saved["shortcut_sequences"][0]["steps"][0]
assert step["game_vmix_mode"] == "text"
assert step["penalty_vmix_mode"] == "text"
def test_build90_native_countdown_avoids_per_second_transport() -> None:
app_js = Path("ui_builder/static/app.js").read_text(encoding="utf-8")
assert 'game_vmix_mode !== "countdown"' in app_js
assert 'const countdownMode = step.penalty_vmix_mode === "countdown"' in app_js
assert 'Function: "PauseCountdown"' in app_js
assert 'Function: "StopCountdown"' in app_js
assert 'signal: controller.signal' in app_js
assert 'vmixCommandQueue: Promise.resolve()' in app_js
# Per-second pushes are restricted to the legacy Text mirror map.
assert 'eventName === "timer_tick" && state.vmixTimerMirrors.has(component.action_id)' in app_js

View File

@@ -11,7 +11,7 @@ from typing import Any
DEFAULT_CONFIG: dict[str, Any] = { DEFAULT_CONFIG: dict[str, Any] = {
"version": 21, "version": 22,
"project_name": "Новый интерфейс", "project_name": "Новый интерфейс",
"data_source": "golf", "data_source": "golf",
"canvas": { "canvas": {
@@ -68,12 +68,15 @@ class UIBuilderManager:
except (OSError, json.JSONDecodeError, TypeError): except (OSError, json.JSONDecodeError, TypeError):
return deepcopy(DEFAULT_CONFIG) return deepcopy(DEFAULT_CONFIG)
had_legacy_shortcuts = self._has_legacy_hockey_component_shortcuts(raw) had_legacy_shortcuts = self._has_legacy_hockey_component_shortcuts(raw)
try:
source_version = int(raw.get("version") or 0) if isinstance(raw, dict) else 0
except (TypeError, ValueError):
source_version = 0
normalized = self._normalize(raw) normalized = self._normalize(raw)
if had_legacy_shortcuts: if had_legacy_shortcuts or source_version < 22:
# Build61 migration: remove old hidden component-level Space/Ctrl+R # Build61: remove old hidden component-level Space/Ctrl+R bindings.
# bindings from the persisted draft/published JSON. Shortcut Sequences # BUILD90: persist the one-time v21 -> v22 native-countdown migration
# are intentionally untouched, so an explicitly configured Space # so old Text mirror timer steps stop producing per-second SetText traffic.
# sequence remains available in the visible shortcut editor.
self.save(normalized, create_backup=False) self.save(normalized, create_backup=False)
return normalized return normalized
@@ -159,7 +162,12 @@ class UIBuilderManager:
if not isinstance(config, dict): if not isinstance(config, dict):
return result return result
result["version"] = 21 source_version_raw = config.get("version", 0)
try:
source_version = int(source_version_raw or 0)
except (TypeError, ValueError):
source_version = 0
result["version"] = 22
result["project_name"] = str(config.get("project_name") or result["project_name"]) result["project_name"] = str(config.get("project_name") or result["project_name"])
result["data_source"] = str(config.get("data_source") or result["data_source"]) result["data_source"] = str(config.get("data_source") or result["data_source"])
@@ -426,8 +434,18 @@ class UIBuilderManager:
"scoreboard_alternate_selected_name": str(step.get("scoreboard_alternate_selected_name") or ""), "scoreboard_alternate_selected_name": str(step.get("scoreboard_alternate_selected_name") or ""),
"game_timer_action_id": str(step.get("game_timer_action_id") or "hockey_game_timer"), "game_timer_action_id": str(step.get("game_timer_action_id") or "hockey_game_timer"),
"hockey_timer_command": str(step.get("hockey_timer_command") or "toggle") if str(step.get("hockey_timer_command") or "toggle") in {"toggle", "start", "pause", "resume"} else "toggle", "hockey_timer_command": str(step.get("hockey_timer_command") or "toggle") if str(step.get("hockey_timer_command") or "toggle") in {"toggle", "start", "pause", "resume"} else "toggle",
"game_vmix_mode": str(step.get("game_vmix_mode") or "text") if str(step.get("game_vmix_mode") or "text") in {"countdown", "text"} else "text", # BUILD90: configs created before v22 used Text mirror as the default,
"penalty_vmix_mode": str(step.get("penalty_vmix_mode") or "text") if str(step.get("penalty_vmix_mode") or "text") in {"countdown", "text"} else "text", # which pushed timer text every second. Migrate those hockey timer
# sync steps once to native vMix countdown transport. From v22 onward
# an explicitly selected legacy Text mirror remains available.
"game_vmix_mode": (
"countdown" if source_version < 22 and step_type == "hockey_vmix_timers_start"
else (str(step.get("game_vmix_mode") or "countdown") if str(step.get("game_vmix_mode") or "countdown") in {"countdown", "text"} else "countdown")
),
"penalty_vmix_mode": (
"countdown" if source_version < 22 and step_type == "hockey_vmix_timers_start"
else (str(step.get("penalty_vmix_mode") or "countdown") if str(step.get("penalty_vmix_mode") or "countdown") in {"countdown", "text"} else "countdown")
),
"penalty_display_mode": "all" if str(step.get("penalty_display_mode") or "soonest") == "all" else "soonest", "penalty_display_mode": "all" if str(step.get("penalty_display_mode") or "soonest") == "all" else "soonest",
"game_vmix_input": str(step.get("game_vmix_input") or ""), "game_vmix_input": str(step.get("game_vmix_input") or ""),
"game_vmix_selected_name": str(step.get("game_vmix_selected_name") or ""), "game_vmix_selected_name": str(step.get("game_vmix_selected_name") or ""),

View File

@@ -32,7 +32,7 @@
const state = { const state = {
config: { config: {
version: 21, version: 22,
project_name: "Новый интерфейс", project_name: "Новый интерфейс",
data_source: "golf", data_source: "golf",
canvas: { canvas: {
@@ -80,6 +80,10 @@
modifierShortcutChordUsedKey: false, modifierShortcutChordUsedKey: false,
modifierShortcutChordFired: false, modifierShortcutChordFired: false,
runningShortcutSequences: new Set(), runningShortcutSequences: new Set(),
// BUILD90: all runtime vMix requests share one browser-side FIFO. This prevents
// two different shortcuts from interleaving commands while an Agent ACK is pending.
vmixCommandQueue: Promise.resolve(),
vmixCommandQueueDepth: 0,
vmixTimerMirrors: new Map(), vmixTimerMirrors: new Map(),
vmixPenaltyMirrors: new Map(), vmixPenaltyMirrors: new Map(),
activeHockeyVmixTimerSteps: new Set(), activeHockeyVmixTimerSteps: new Set(),
@@ -2380,8 +2384,8 @@ function startCustomTooltips() {
scoreboard_alternate_selected_name: String(step.scoreboard_alternate_selected_name || ""), scoreboard_alternate_selected_name: String(step.scoreboard_alternate_selected_name || ""),
game_timer_action_id: String(step.game_timer_action_id || "hockey_game_timer"), game_timer_action_id: String(step.game_timer_action_id || "hockey_game_timer"),
hockey_timer_command: ["toggle", "start", "pause", "resume"].includes(String(step.hockey_timer_command || "")) ? String(step.hockey_timer_command) : "toggle", hockey_timer_command: ["toggle", "start", "pause", "resume"].includes(String(step.hockey_timer_command || "")) ? String(step.hockey_timer_command) : "toggle",
game_vmix_mode: ["countdown", "text"].includes(String(step.game_vmix_mode || "")) ? String(step.game_vmix_mode) : "text", game_vmix_mode: ["countdown", "text"].includes(String(step.game_vmix_mode || "")) ? String(step.game_vmix_mode) : "countdown",
penalty_vmix_mode: ["countdown", "text"].includes(String(step.penalty_vmix_mode || "")) ? String(step.penalty_vmix_mode) : "text", penalty_vmix_mode: ["countdown", "text"].includes(String(step.penalty_vmix_mode || "")) ? String(step.penalty_vmix_mode) : "countdown",
penalty_display_mode: String(step.penalty_display_mode || "soonest") === "all" ? "all" : "soonest", penalty_display_mode: String(step.penalty_display_mode || "soonest") === "all" ? "all" : "soonest",
game_vmix_input: String(step.game_vmix_input || ""), game_vmix_input: String(step.game_vmix_input || ""),
game_vmix_selected_name: String(step.game_vmix_selected_name || ""), game_vmix_selected_name: String(step.game_vmix_selected_name || ""),
@@ -2571,7 +2575,7 @@ function startCustomTooltips() {
} }
function ensureConfig() { function ensureConfig() {
state.config.version = 21; state.config.version = 22;
state.config.canvas ||= {}; state.config.canvas ||= {};
state.config.canvas.auto_bind_containers = state.config.canvas.auto_bind_containers !== false; state.config.canvas.auto_bind_containers = state.config.canvas.auto_bind_containers !== false;
state.config.tabs = Array.isArray(state.config.tabs) && state.config.tabs.length ? state.config.tabs : [{ id: "main", label: "Основное" }]; state.config.tabs = Array.isArray(state.config.tabs) && state.config.tabs.length ? state.config.tabs : [{ id: "main", label: "Основное" }];
@@ -2719,7 +2723,7 @@ function startCustomTooltips() {
const factory = templates[name]; const factory = templates[name];
if (!factory) return; if (!factory) return;
const next = factory(); const next = factory();
state.config = { version: 21, triggers: [], ...next }; state.config = { version: 22, triggers: [], ...next };
ensureConfig(); ensureConfig();
state.activeTab = state.config.tabs[0]?.id || "main"; state.activeTab = state.config.tabs[0]?.id || "main";
if (state.config.canvas.auto_bind_containers) { if (state.config.canvas.auto_bind_containers) {
@@ -4504,29 +4508,53 @@ function startCustomTooltips() {
async function sendRuntimeVmixSequence(commands, execution = null) { async function sendRuntimeVmixSequence(commands, execution = null) {
const clean = (commands || []).map(compactVmixCommand).filter((command) => command.Function); const clean = (commands || []).map(compactVmixCommand).filter((command) => command.Function);
if (!clean.length) return { ok: true, applied: 0, results: [] }; if (!clean.length) return { ok: true, applied: 0, results: [] };
const response = await fetch("/api/hockey/vmix/sequence", {
method: "POST", const run = async () => {
cache: "no-store", state.vmixCommandQueueDepth += 1;
credentials: "same-origin", const controller = new AbortController();
headers: { "Content-Type": "application/json" }, const timeoutId = window.setTimeout(() => controller.abort(), 7000);
body: JSON.stringify({ try {
commands: clean, const response = await fetch("/api/hockey/vmix/sequence", {
device_id: currentRuntimeVmixDeviceId(), method: "POST",
session_token: currentRuntimeHockeySessionToken(), cache: "no-store",
sequence_id: String(execution?.sequence_id || ""), credentials: "same-origin",
sequence_name: String(execution?.sequence_name || ""), signal: controller.signal,
button_id: String(execution?.button_id || ""), headers: { "Content-Type": "application/json" },
}), body: JSON.stringify({
}); commands: clean,
let payload = {}; device_id: currentRuntimeVmixDeviceId(),
try { payload = await response.json(); } catch (_) {} session_token: currentRuntimeHockeySessionToken(),
if (!response.ok) { sequence_id: String(execution?.sequence_id || ""),
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`; sequence_name: String(execution?.sequence_name || ""),
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); button_id: String(execution?.button_id || ""),
} }),
trackRuntimeOverlayCommands(clean, execution); });
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state); let payload = {};
return payload; try { payload = await response.json(); } catch (_) {}
if (!response.ok) {
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
}
trackRuntimeOverlayCommands(clean, execution);
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
return payload;
} catch (error) {
if (error?.name === "AbortError") {
throw new Error("vMix/Agent не подтвердил команду за 7 секунд");
}
throw error;
} finally {
clearTimeout(timeoutId);
state.vmixCommandQueueDepth = Math.max(0, state.vmixCommandQueueDepth - 1);
}
};
// Keep the queue alive after a failed command: one timeout must not permanently
// block every shortcut pressed afterwards. No automatic retry is performed because
// toggle/overlay commands are not safely idempotent.
const queued = state.vmixCommandQueue.catch(() => {}).then(run);
state.vmixCommandQueue = queued.catch(() => {});
return queued;
} }
function splitVmixInputs(value) { function splitVmixInputs(value) {
@@ -4567,6 +4595,36 @@ function startCustomTooltips() {
} }
} }
async function syncActiveVmixGameCountdown(component, timerState, eventName) {
if (!component?.action_id || eventName === "timer_tick") return false;
const commands = [];
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
const step = hockeyTimerSyncStepById(stepId);
if (!step || step.enabled === false || !step.sync_vmix_game || step.game_vmix_mode !== "countdown") continue;
if (String(step.game_timer_action_id || "hockey_game_timer") !== String(component.action_id)) continue;
const input = String(step.game_vmix_input || "").trim();
const selectedName = String(step.game_vmix_selected_name || "").trim();
if (!input || !selectedName) continue;
const target = { Input: input, SelectedName: selectedName };
if (["timer_start", "timer_restart"].includes(eventName)) {
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
commands.push({ Function: "StartCountdown", ...target });
} else if (eventName === "timer_resume") {
commands.push({ Function: "StartCountdown", ...target });
} else if (eventName === "timer_pause") {
commands.push({ Function: "PauseCountdown", ...target });
} else if (["timer_stop", "timer_finished"].includes(eventName)) {
commands.push({ Function: "StopCountdown", ...target });
} else if (["timer_reset", "timer_set_time", "timer_add_time", "timer_subtract_time"].includes(eventName)) {
commands.push({ Function: "SetCountdown", ...target, Value: vmixCountdownValue(timerState.currentMs) });
commands.push({ Function: timerState.running ? "StartCountdown" : "PauseCountdown", ...target });
}
}
if (!commands.length) return false;
await sendRuntimeVmixSequence(commands);
return true;
}
function penaltyMirrorKey(component, event) { function penaltyMirrorKey(component, event) {
return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`; return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`;
@@ -4792,7 +4850,9 @@ function startCustomTooltips() {
async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true } = {}) { async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true } = {}) {
const outCommands = []; const outCommands = [];
const stopCommands = [];
const setCommands = []; const setCommands = [];
const runCommands = [];
const inCommands = []; const inCommands = [];
const assignedMirrorKeys = new Set(); const assignedMirrorKeys = new Set();
for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) { for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) {
@@ -4801,7 +4861,7 @@ function startCustomTooltips() {
state.activeHockeyVmixTimerSteps.delete(stepId); state.activeHockeyVmixTimerSteps.delete(stepId);
continue; continue;
} }
if (step.penalty_vmix_mode !== "text") continue; const countdownMode = step.penalty_vmix_mode === "countdown";
const displayPlan = penaltyDisplayEntriesByTargetSide(step); const displayPlan = penaltyDisplayEntriesByTargetSide(step);
rememberPenaltyAdvantagePlan(displayPlan); rememberPenaltyAdvantagePlan(displayPlan);
for (const side of ["home", "away"]) { for (const side of ["home", "away"]) {
@@ -4816,32 +4876,50 @@ function startCustomTooltips() {
const entry = entries[index] || null; const entry = entries[index] || null;
if (entry && target.input && target.selected_name) { if (entry && target.input && target.selected_name) {
const eventKey = penaltyMirrorKey(entry.component, entry.event); const eventKey = penaltyMirrorKey(entry.component, entry.event);
assignedMirrorKeys.add(eventKey); const sourceSide = String(entry.side || entry.event?.side || entry.event?.player?.side || side);
setVmixPenaltyMirror(entry.component, entry.event, target.input, target.selected_name, { const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
stepId: step.id, const assignmentChanged = !previous
targetId: target.id, || previous.eventKey !== eventKey
side: String(entry.side || entry.event?.side || entry.event?.player?.side || side), || String(previous.input || "") !== String(target.input)
overlay: target.overlay, || String(previous.selectedName || "") !== String(target.selected_name);
});
state.vmixPenaltyTargetAssignments.set(assignmentKey, { if (previous && countdownMode && assignmentChanged && previous.input && String(previous.input) !== String(target.input)) {
eventKey, stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name });
input: target.input, }
selectedName: target.selected_name,
overlay: target.overlay, if (countdownMode) {
sourceSide: String(entry.side || entry.event?.side || entry.event?.player?.side || ""), state.vmixPenaltyMirrors.delete(eventKey);
targetSide: side, state.vmixPenaltyTargetAssignments.set(assignmentKey, {
}); eventKey, input: target.input, selectedName: target.selected_name, overlay, sourceSide, targetSide: side,
const value = formatHockeyPenaltyTime(entry.event.remainingMs); mode: "countdown", running: Boolean(entry.event.running),
const mirror = state.vmixPenaltyMirrors.get(eventKey); });
if (force || !mirror || mirror.lastValue !== value || previous?.eventKey !== eventKey) { if (force || assignmentChanged) {
setCommands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: value }); setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) });
if (mirror) mirror.lastValue = value; }
if (entry.event.running) {
if (force || assignmentChanged || previous?.running !== true) {
runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
}
} else if (force || assignmentChanged || previous?.running !== false) {
runCommands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
}
} else {
assignedMirrorKeys.add(eventKey);
setVmixPenaltyMirror(entry.component, entry.event, target.input, target.selected_name, {
stepId: step.id, targetId: target.id, side: sourceSide, overlay,
});
state.vmixPenaltyTargetAssignments.set(assignmentKey, {
eventKey, input: target.input, selectedName: target.selected_name, overlay, sourceSide, targetSide: side, mode: "text",
});
const value = formatHockeyPenaltyTime(entry.event.remainingMs);
const mirror = state.vmixPenaltyMirrors.get(eventKey);
if (force || !mirror || mirror.lastValue !== value || previous?.eventKey !== eventKey) {
setCommands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: value });
if (mirror) mirror.lastValue = value;
}
} }
// If the scoreboard is already on air and this penalty target was not
// previously assigned, bring the penalty plate on air immediately.
if (hockeyScoreboardIsLive()) { if (hockeyScoreboardIsLive()) {
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
const targetWasVisible = Boolean(previous?.input) const targetWasVisible = Boolean(previous?.input)
&& String(previous.input) === String(target.input) && String(previous.input) === String(target.input)
&& String(previous.overlay || overlay) === overlay; && String(previous.overlay || overlay) === overlay;
@@ -4854,6 +4932,9 @@ function startCustomTooltips() {
} }
} }
} else { } else {
if (previous?.mode === "countdown" && previous.input) {
stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name });
}
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) { if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2"; const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input }); outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
@@ -4864,6 +4945,9 @@ function startCustomTooltips() {
inactiveTargets.forEach((target) => { inactiveTargets.forEach((target) => {
const assignmentKey = penaltyTargetAssignmentKey(step, side, target); const assignmentKey = penaltyTargetAssignmentKey(step, side, target);
const previous = state.vmixPenaltyTargetAssignments.get(assignmentKey); const previous = state.vmixPenaltyTargetAssignments.get(assignmentKey);
if (previous?.mode === "countdown" && previous.input) {
stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name });
}
if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) { if (previous && hideUnused && target.auto_hide_on_finish !== false && target.input) {
const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2"; const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2";
outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input }); outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input });
@@ -4877,10 +4961,8 @@ function startCustomTooltips() {
state.vmixPenaltyMirrors.delete(mirrorKey); state.vmixPenaltyMirrors.delete(mirrorKey);
} }
} }
// Always take the old single plate OUT before putting the new side IN. // Old plate OUT/Stop first, then set/start the single current countdown, then IN.
// HOME and AWAY targets frequently share the same Overlay slot; sending IN const commands = [...outCommands, ...stopCommands, ...setCommands, ...runCommands, ...inCommands];
// first and OUT second would remove the newly selected plate.
const commands = [...outCommands, ...setCommands, ...inCommands];
if (commands.length) await sendRuntimeVmixSequence(commands); if (commands.length) await sendRuntimeVmixSequence(commands);
return commands.length; return commands.length;
} }
@@ -4986,6 +5068,7 @@ function startCustomTooltips() {
const action = configuredCommand === "toggle" ? (gameTimerState?.running ? "pause" : "start") : configuredCommand; const action = configuredCommand === "toggle" ? (gameTimerState?.running ? "pause" : "start") : configuredCommand;
const pausing = action === "pause"; const pausing = action === "pause";
const commands = []; const commands = [];
state.activeHockeyVmixTimerSteps.add(step.id);
if (step.sync_vmix_game && step.game_vmix_input) { if (step.sync_vmix_game && step.game_vmix_input) {
if (!gameTimerState) throw new Error(`Основной таймер «${step.game_timer_action_id}» не найден`); if (!gameTimerState) throw new Error(`Основной таймер «${step.game_timer_action_id}» не найден`);
@@ -4994,10 +5077,12 @@ function startCustomTooltips() {
setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name); setVmixTimerMirror(step.game_timer_action_id || "hockey_game_timer", step.game_vmix_input, step.game_vmix_selected_name);
commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) }); commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) });
} else if (pausing) { } else if (pausing) {
commands.push({ Function: "SuspendCountdown", Input: step.game_vmix_input }); commands.push({ Function: "PauseCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
} else if (action === "resume") {
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
} else { } else {
commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) }); commands.push({ Function: "SetCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: vmixCountdownValue(gameTimerState.currentMs) });
commands.push({ Function: "StartCountdown", Input: step.game_vmix_input }); commands.push({ Function: "StartCountdown", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name });
} }
} }
@@ -5013,18 +5098,25 @@ function startCustomTooltips() {
const target = targets[index] || null; const target = targets[index] || null;
if (!target?.input) return; if (!target?.input) return;
if (!target.selected_name) throw new Error(`Для таймера удаления ${side === "home" ? "HOME" : "AWAY"} выберите Text / SelectedName`); if (!target.selected_name) throw new Error(`Для таймера удаления ${side === "home" ? "HOME" : "AWAY"} выберите Text / SelectedName`);
const sourceSide = String(event.player?.side || event.side || side);
if (step.penalty_vmix_mode === "text") { if (step.penalty_vmix_mode === "text") {
const sourceSide = String(event.player?.side || event.side || side);
setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay: target.overlay }); setVmixPenaltyMirror(component, event, target.input, target.selected_name, { stepId: step.id, targetId: target.id, side: sourceSide, overlay: target.overlay });
state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), { state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "text",
}); });
commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) }); commands.push({ Function: "SetText", Input: target.input, SelectedName: target.selected_name, Value: formatHockeyPenaltyTime(event.remainingMs) });
} else if (pausing) {
commands.push({ Function: "SuspendCountdown", Input: target.input });
} else { } else {
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) }); state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), {
commands.push({ Function: "StartCountdown", Input: target.input }); eventKey: penaltyMirrorKey(component, event), input: target.input, selectedName: target.selected_name, overlay: target.overlay, sourceSide, targetSide: side, mode: "countdown", running: !pausing,
});
if (pausing) {
commands.push({ Function: "PauseCountdown", Input: target.input, SelectedName: target.selected_name });
} else if (action === "resume") {
commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
} else {
commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) });
commands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
}
} }
}); });
}); });
@@ -5033,7 +5125,7 @@ function startCustomTooltips() {
const vmixResult = commands.length ? await sendRuntimeVmixSequence(commands, execution) : { ok: true, applied: 0 }; const vmixResult = commands.length ? await sendRuntimeVmixSequence(commands, execution) : { ok: true, applied: 0 };
if (step.start_web_game) { if (step.start_web_game) {
if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"))) { if (!controlTimer(step.game_timer_action_id || "hockey_game_timer", pausing ? "pause" : (action === "resume" ? "resume" : "start"), "", { syncVmix: false })) {
throw new Error(`Основной таймер «${step.game_timer_action_id || "hockey_game_timer"}» не найден`); throw new Error(`Основной таймер «${step.game_timer_action_id || "hockey_game_timer"}» не найден`);
} }
if (step.game_vmix_mode === "text" && gameTimer && gameTimerState) { if (step.game_vmix_mode === "text" && gameTimer && gameTimerState) {
@@ -5041,7 +5133,7 @@ function startCustomTooltips() {
} }
} }
if (step.start_web_penalties) { if (step.start_web_penalties) {
penalties.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : "start")); penalties.forEach(({ component, event }) => controlHockeyPenalty(component, event.id, pausing ? "pause" : (action === "resume" ? "start" : "start"), "", { syncVmix: false }));
} }
if (step.penalty_vmix_mode === "text" && !pausing) { if (step.penalty_vmix_mode === "text" && !pausing) {
for (const { component, event } of penalties) { for (const { component, event } of penalties) {
@@ -5532,7 +5624,10 @@ function applyExternalDataPatch(patch, { render = true } = {}) {
...detail ...detail
}); });
if (eventName === "timer_tick" && state.vmixTimerMirrors.has(component.action_id)) { if (eventName === "timer_tick" && state.vmixTimerMirrors.has(component.action_id)) {
// Legacy Text mirror only. Native countdown mode never emits a per-second request.
pushVmixTimerMirror(component, timerState).catch(() => {}); pushVmixTimerMirror(component, timerState).catch(() => {});
} else if (eventName !== "timer_tick" && !detail.suppressVmixSync) {
syncActiveVmixGameCountdown(component, timerState, eventName).catch((error) => console.error("vMix game countdown sync error", error));
} }
if (eventName === "timer_finished") { if (eventName === "timer_finished") {
fireConfiguredTimerFinishActions("game", { gameActionId: component.action_id, component, timerState }); fireConfiguredTimerFinishActions("game", { gameActionId: component.action_id, component, timerState });
@@ -5632,7 +5727,7 @@ function applyExternalDataPatch(patch, { render = true } = {}) {
requestAnimationFrame(timerEngineFrame); requestAnimationFrame(timerEngineFrame);
} }
function controlTimer(actionId, command = "toggle", rawValue = "") { function controlTimer(actionId, command = "toggle", rawValue = "", options = {}) {
const component = componentByActionId(actionId); const component = componentByActionId(actionId);
if (!component || !isTimerComponent(component)) return false; if (!component || !isTimerComponent(component)) return false;
const timerState = ensureTimerState(component); const timerState = ensureTimerState(component);
@@ -5718,7 +5813,7 @@ function applyExternalDataPatch(patch, { render = true } = {}) {
timerState.lastTimestamp = now; timerState.lastTimestamp = now;
persistTimer(component, timerState, true); persistTimer(component, timerState, true);
emitTimerEvent(component, eventName, timerState, { command, amount: rawValue }); emitTimerEvent(component, eventName, timerState, { command, amount: rawValue, suppressVmixSync: options.syncVmix === false });
return true; return true;
} }
@@ -6741,7 +6836,7 @@ function openTimerQuickEditor(focusActionId = "") {
board.selectedPreset = null; board.selectedPreset = null;
} }
function controlHockeyPenalty(component, eventId, command, rawValue = "") { function controlHockeyPenalty(component, eventId, command, rawValue = "", options = {}) {
const board = ensureHockeyBoardState(component); const board = ensureHockeyBoardState(component);
const event = board.penalties.find((item) => item.id === eventId); const event = board.penalties.find((item) => item.id === eventId);
if (!event) return false; if (!event) return false;
@@ -6860,6 +6955,10 @@ function openTimerQuickEditor(focusActionId = "") {
persistHockeyBoard(component, board, true); persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component); refreshHockeyBoardNodes(component);
if (options.syncVmix !== false && state.activeHockeyVmixTimerSteps.size && ["start", "pause", "reset", "set_time"].includes(command)) {
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
.catch((error) => console.error("Penalty countdown state sync error", error));
}
return true; return true;
} }
@@ -13327,10 +13426,10 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<div class="shortcut-step-grid vmix-command-grid"> <div class="shortcut-step-grid vmix-command-grid">
<label>Действие<select data-step-field="hockey_timer_command">${triggerSelectOptions([["toggle","Старт / пауза одной кнопкой"],["start","Только запустить"],["pause","Только пауза"],["resume","Продолжить"]], step.hockey_timer_command)}</select></label> <label>Действие<select data-step-field="hockey_timer_command">${triggerSelectOptions([["toggle","Старт / пауза одной кнопкой"],["start","Только запустить"],["pause","Только пауза"],["resume","Продолжить"]], step.hockey_timer_command)}</select></label>
<label>Основной веб-таймер<select data-step-field="game_timer_action_id">${timerActionOptions(step.game_timer_action_id)}</select></label> <label>Основной веб-таймер<select data-step-field="game_timer_action_id">${timerActionOptions(step.game_timer_action_id)}</select></label>
<label>Режим основного таймера в vMix<select data-step-field="game_vmix_mode">${triggerSelectOptions([["text","Text mirror · рекомендуется"],["countdown","Встроенный Countdown vMix"]], step.game_vmix_mode)}</select></label> <label>Режим основного таймера в vMix<select data-step-field="game_vmix_mode">${triggerSelectOptions([["countdown","Встроенный Countdown vMix · рекомендуется"],["text","Text mirror · совместимость"]], step.game_vmix_mode)}</select></label>
<label>vMix Input основного таймера<select data-step-field="game_vmix_input">${vmixInputOptions(step.game_vmix_input)}</select></label> <label>vMix Input основного таймера<select data-step-field="game_vmix_input">${vmixInputOptions(step.game_vmix_input)}</select></label>
<label>Text / SelectedName основного таймера<select data-step-field="game_vmix_selected_name">${vmixTextSelectedNameOptions(step.game_vmix_input, step.game_vmix_selected_name)}</select></label> <label>Text / SelectedName основного таймера<select data-step-field="game_vmix_selected_name">${vmixTextSelectedNameOptions(step.game_vmix_input, step.game_vmix_selected_name)}</select></label>
<label>Режим таймеров удалений<select data-step-field="penalty_vmix_mode">${triggerSelectOptions([["text","Text mirror · рекомендуется"],["countdown","Встроенный Countdown vMix"]], step.penalty_vmix_mode)}</select></label> <label>Режим таймеров удалений<select data-step-field="penalty_vmix_mode">${triggerSelectOptions([["countdown","Встроенный Countdown vMix · рекомендуется"],["text","Text mirror · совместимость"]], step.penalty_vmix_mode)}</select></label>
<label>Что показывать при нескольких удалениях<select data-step-field="penalty_display_mode">${triggerSelectOptions([["soonest","Одно ближайшее окончание"],["all","Все удаления по слотам"]], step.penalty_display_mode)}</select></label> <label>Что показывать при нескольких удалениях<select data-step-field="penalty_display_mode">${triggerSelectOptions([["soonest","Одно ближайшее окончание"],["all","Все удаления по слотам"]], step.penalty_display_mode)}</select></label>
</div> </div>
@@ -13360,7 +13459,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div> <div class="shortcut-finish-action-list">${timerFinishActionRows(step)}</div>
<div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div> <div class="shortcut-vmix-inventory-note"><span>${escapeHtml(shortcutInventoryLabel())}</span><small>Для каждого countdown теперь обязательно выбирается конкретный Text / SelectedName. Это исключает отправку времени в первый текстовый элемент по умолчанию.</small></div>
<p class="shortcut-step-note">Режим <b>Text mirror</b> рекомендуется: веб-таймер является источником истины и раз в секунду отправляет <code>SetText</code> строго в выбранные <code>Input + SelectedName</code>. Режим <b>Countdown</b> оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление. При сложных/обоюдных удалениях, пока штрафы есть у обеих команд, HOME/AWAY остаются на своих сторонах и режим «играют в большинстве» не включается. Только когда одна сторона полностью очистится, оставшийся таймер переезжает на Input противоположной команды — стороны большинства. Если обе стороны очистились одновременно, дополнительные плашки просто снимаются. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p> <p class="shortcut-step-note">Режим <b>Countdown vMix</b> рекомендуется и используется по умолчанию: веб отправляет <code>SetCountdown</code> только при установке/коррекции времени и затем <code>StartCountdown</code>; каждую секунду значение больше не передаётся. <b>Text mirror</b> оставлен только как режим совместимости для старых титров. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывается на стороне команды преимущества и отсчитывает ближайшее изменение численного состава; при чистом равном обоюдном удалении плашка не выводится. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.</p>
</div>`; </div>`;
} else if (step.type === "delay") { } else if (step.type === "delay") {
body.innerHTML = `<div class="shortcut-step-grid"><label>Задержка, мс<input type="number" min="0" max="10000" step="10" data-step-field="milliseconds" value="${Number(step.milliseconds) || 0}"></label></div>`; body.innerHTML = `<div class="shortcut-step-grid"><label>Задержка, мс<input type="number" min="0" max="10000" step="10" data-step-field="milliseconds" value="${Number(step.milliseconds) || 0}"></label></div>`;