diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py index 0d3b011..14c310b 100644 --- a/hockey_data/agent_bridge.py +++ b/hockey_data/agent_bridge.py @@ -238,6 +238,9 @@ class VmixAgentHub: self._live: dict[str, LiveAgent] = {} self._lock = asyncio.Lock() 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._auto_refresh_task: asyncio.Task[None] | None = None self._auto_refresh_next: dict[tuple[str, str], float] = {} @@ -663,6 +666,13 @@ class VmixAgentHub: self._live.pop(device_id, None) 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: request_id = str(message.get("request_id") or "").strip() if not request_id: @@ -677,7 +687,7 @@ class VmixAgentHub: payload["device_id"] = device_id future.set_result(payload) - async def send_vmix_command( + async def _send_vmix_command_unlocked( self, device_id: str, *, @@ -717,7 +727,21 @@ class VmixAgentHub: if not pending_future.done(): 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, device_id: str, *, @@ -757,6 +781,20 @@ class VmixAgentHub: if not pending_future.done(): 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( self, device_id: str, @@ -1286,12 +1324,13 @@ class VmixAgentHub: raise HTTPException(status_code=404, detail="Agent не найден") assignment_id = str(device.current_assignment_key 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: raise HTTPException(status_code=409, detail="Agent не привязан к текущему матчу") if operator_game_id and match_id != operator_game_id: raise HTTPException(status_code=409, detail="Agent назначен на другой матч") - results: list[dict[str, Any]] = [] + prepared_commands: list[dict[str, Any]] = [] for index, raw in enumerate(commands): function = str(raw.get("Function") or raw.get("function") or "").strip() if not function: @@ -1303,36 +1342,62 @@ class VmixAgentHub: if str(key).lower() != "value" and str(value) == "": continue command[str(key)] = value - ack = await self.send_vmix_command( - 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, - }, + prepared_commands.append(command) + + results: list[dict[str, Any]] = [] + transport = "batch" if self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 else "legacy" + async with self._device_vmix_send_lock(target_device_id): + if transport == "batch": + ack = await self._send_vmix_batch_unlocked( + target_device_id, + assignment_id=assignment_id, + match_id=match_id, + commands=prepared_commands, + timeout=max(4.0, min(8.0, timeout + 2.0)), ) - self._track_runtime_overlay_command( - target_device_id, - command, - sequence_id=sequence_id, - sequence_name=sequence_name, - button_id=button_id, - ) + ack_results = ack.get("results") if isinstance(ack.get("results"), list) else [] + for index, command in enumerate(prepared_commands): + function = str(command.get("Function") or "") + item_ack = ack_results[index] if index < len(ack_results) and isinstance(ack_results[index], dict) else {} + ok = bool(item_ack.get("ok")) if item_ack else bool(ack.get("ok")) + 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 { "ok": True, "device_id": target_device_id, @@ -1341,6 +1406,7 @@ class VmixAgentHub: "session_token": session_token, "applied": len(results), "results": results, + "transport": transport, "overlay_state": self._runtime_overlay_payload(target_device_id), } diff --git a/tests/test_agent_bridge.py b/tests/test_agent_bridge.py index 74a91e9..811e722 100644 --- a/tests/test_agent_bridge.py +++ b/tests/test_agent_bridge.py @@ -688,7 +688,7 @@ def test_runtime_vmix_sequence_preserves_command_order(tmp_path: Path) -> None: "device_secret": "s" * 40, "device_name": "Sequence GFX", "hostname": "SEQUENCE-PC", - "agent_version": "1.4.0", + "agent_version": "1.3.0", "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 assert result["ok"] is True assert result["applied"] == 2 + assert result["transport"] == "legacy" assert [item["function"] for item in result["results"]] == ["SetCountdown", "StartCountdown"] 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()) diff --git a/tests/test_build89_single_penalty_plate_transition.py b/tests/test_build89_single_penalty_plate_transition.py index b1e9941..76fc53a 100644 --- a/tests/test_build89_single_penalty_plate_transition.py +++ b/tests/test_build89_single_penalty_plate_transition.py @@ -88,4 +88,6 @@ def test_rebalance_sends_old_plate_out_before_new_plate_in(): assert 'const outCommands = [];' in block assert 'const setCommands = [];' 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 diff --git a/tests/test_shortcut_sequences.py b/tests/test_shortcut_sequences.py index 2b5626c..6d20ccb 100644 --- a/tests/test_shortcut_sequences.py +++ b/tests/test_shortcut_sequences.py @@ -104,9 +104,10 @@ def test_shortcut_editor_layout_fixes_add_step_buttons_and_fullscreen_modal() -> 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") - 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 'game_vmix_mode' 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 for layer in range(1, 5): 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 diff --git a/ui_builder/manager.py b/ui_builder/manager.py index b265057..76e923c 100644 --- a/ui_builder/manager.py +++ b/ui_builder/manager.py @@ -11,7 +11,7 @@ from typing import Any DEFAULT_CONFIG: dict[str, Any] = { - "version": 21, + "version": 22, "project_name": "Новый интерфейс", "data_source": "golf", "canvas": { @@ -68,12 +68,15 @@ class UIBuilderManager: except (OSError, json.JSONDecodeError, TypeError): return deepcopy(DEFAULT_CONFIG) 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) - if had_legacy_shortcuts: - # Build61 migration: remove old hidden component-level Space/Ctrl+R - # bindings from the persisted draft/published JSON. Shortcut Sequences - # are intentionally untouched, so an explicitly configured Space - # sequence remains available in the visible shortcut editor. + if had_legacy_shortcuts or source_version < 22: + # Build61: remove old hidden component-level Space/Ctrl+R bindings. + # BUILD90: persist the one-time v21 -> v22 native-countdown migration + # so old Text mirror timer steps stop producing per-second SetText traffic. self.save(normalized, create_backup=False) return normalized @@ -159,7 +162,12 @@ class UIBuilderManager: if not isinstance(config, dict): 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["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 ""), "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", - "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", - "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", + # BUILD90: configs created before v22 used Text mirror as the default, + # 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", "game_vmix_input": str(step.get("game_vmix_input") or ""), "game_vmix_selected_name": str(step.get("game_vmix_selected_name") or ""), diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js index a69b8f9..2276e41 100644 --- a/ui_builder/static/app.js +++ b/ui_builder/static/app.js @@ -32,7 +32,7 @@ const state = { config: { - version: 21, + version: 22, project_name: "Новый интерфейс", data_source: "golf", canvas: { @@ -80,6 +80,10 @@ modifierShortcutChordUsedKey: false, modifierShortcutChordFired: false, 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(), vmixPenaltyMirrors: new Map(), activeHockeyVmixTimerSteps: new Set(), @@ -2380,8 +2384,8 @@ function startCustomTooltips() { scoreboard_alternate_selected_name: String(step.scoreboard_alternate_selected_name || ""), 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", - game_vmix_mode: ["countdown", "text"].includes(String(step.game_vmix_mode || "")) ? String(step.game_vmix_mode) : "text", - penalty_vmix_mode: ["countdown", "text"].includes(String(step.penalty_vmix_mode || "")) ? String(step.penalty_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) : "countdown", penalty_display_mode: String(step.penalty_display_mode || "soonest") === "all" ? "all" : "soonest", game_vmix_input: String(step.game_vmix_input || ""), game_vmix_selected_name: String(step.game_vmix_selected_name || ""), @@ -2571,7 +2575,7 @@ function startCustomTooltips() { } function ensureConfig() { - state.config.version = 21; + state.config.version = 22; state.config.canvas ||= {}; 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: "Основное" }]; @@ -2719,7 +2723,7 @@ function startCustomTooltips() { const factory = templates[name]; if (!factory) return; const next = factory(); - state.config = { version: 21, triggers: [], ...next }; + state.config = { version: 22, triggers: [], ...next }; ensureConfig(); state.activeTab = state.config.tabs[0]?.id || "main"; if (state.config.canvas.auto_bind_containers) { @@ -4504,29 +4508,53 @@ function startCustomTooltips() { async function sendRuntimeVmixSequence(commands, execution = null) { const clean = (commands || []).map(compactVmixCommand).filter((command) => command.Function); if (!clean.length) return { ok: true, applied: 0, results: [] }; - const response = await fetch("/api/hockey/vmix/sequence", { - method: "POST", - cache: "no-store", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - commands: clean, - device_id: currentRuntimeVmixDeviceId(), - session_token: currentRuntimeHockeySessionToken(), - sequence_id: String(execution?.sequence_id || ""), - sequence_name: String(execution?.sequence_name || ""), - button_id: String(execution?.button_id || ""), - }), - }); - let 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; + + const run = async () => { + state.vmixCommandQueueDepth += 1; + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), 7000); + try { + const response = await fetch("/api/hockey/vmix/sequence", { + method: "POST", + cache: "no-store", + credentials: "same-origin", + signal: controller.signal, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + commands: clean, + device_id: currentRuntimeVmixDeviceId(), + session_token: currentRuntimeHockeySessionToken(), + sequence_id: String(execution?.sequence_id || ""), + sequence_name: String(execution?.sequence_name || ""), + button_id: String(execution?.button_id || ""), + }), + }); + let 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) { @@ -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) { return `${String(component?.action_id || "hockey_penalty_dashboard")}:${String(event?.id || "")}`; @@ -4792,7 +4850,9 @@ function startCustomTooltips() { async function rebalanceVmixPenaltyTargets({ force = false, hideUnused = true } = {}) { const outCommands = []; + const stopCommands = []; const setCommands = []; + const runCommands = []; const inCommands = []; const assignedMirrorKeys = new Set(); for (const stepId of Array.from(state.activeHockeyVmixTimerSteps)) { @@ -4801,7 +4861,7 @@ function startCustomTooltips() { state.activeHockeyVmixTimerSteps.delete(stepId); continue; } - if (step.penalty_vmix_mode !== "text") continue; + const countdownMode = step.penalty_vmix_mode === "countdown"; const displayPlan = penaltyDisplayEntriesByTargetSide(step); rememberPenaltyAdvantagePlan(displayPlan); for (const side of ["home", "away"]) { @@ -4816,32 +4876,50 @@ function startCustomTooltips() { const entry = entries[index] || null; if (entry && target.input && target.selected_name) { const eventKey = penaltyMirrorKey(entry.component, entry.event); - assignedMirrorKeys.add(eventKey); - setVmixPenaltyMirror(entry.component, entry.event, target.input, target.selected_name, { - stepId: step.id, - targetId: target.id, - side: String(entry.side || entry.event?.side || entry.event?.player?.side || side), - overlay: target.overlay, - }); - state.vmixPenaltyTargetAssignments.set(assignmentKey, { - eventKey, - input: target.input, - selectedName: target.selected_name, - overlay: target.overlay, - sourceSide: String(entry.side || entry.event?.side || entry.event?.player?.side || ""), - targetSide: side, - }); - 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; + const sourceSide = String(entry.side || entry.event?.side || entry.event?.player?.side || side); + const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2"; + const assignmentChanged = !previous + || previous.eventKey !== eventKey + || String(previous.input || "") !== String(target.input) + || String(previous.selectedName || "") !== String(target.selected_name); + + if (previous && countdownMode && assignmentChanged && previous.input && String(previous.input) !== String(target.input)) { + stopCommands.push({ Function: "StopCountdown", Input: previous.input, SelectedName: previous.selectedName || target.selected_name }); + } + + if (countdownMode) { + state.vmixPenaltyMirrors.delete(eventKey); + state.vmixPenaltyTargetAssignments.set(assignmentKey, { + eventKey, input: target.input, selectedName: target.selected_name, overlay, sourceSide, targetSide: side, + mode: "countdown", running: Boolean(entry.event.running), + }); + if (force || assignmentChanged) { + setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(entry.event.remainingMs) }); + } + 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()) { - const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2"; const targetWasVisible = Boolean(previous?.input) && String(previous.input) === String(target.input) && String(previous.overlay || overlay) === overlay; @@ -4854,6 +4932,9 @@ function startCustomTooltips() { } } } 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) { const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2"; outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input }); @@ -4864,6 +4945,9 @@ function startCustomTooltips() { inactiveTargets.forEach((target) => { const assignmentKey = penaltyTargetAssignmentKey(step, side, target); 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) { const overlay = ["1", "2", "3", "4"].includes(String(target.overlay || "")) ? String(target.overlay) : "2"; outCommands.push({ Function: `OverlayInput${overlay}Out`, Input: target.input }); @@ -4877,10 +4961,8 @@ function startCustomTooltips() { state.vmixPenaltyMirrors.delete(mirrorKey); } } - // Always take the old single plate OUT before putting the new side IN. - // HOME and AWAY targets frequently share the same Overlay slot; sending IN - // first and OUT second would remove the newly selected plate. - const commands = [...outCommands, ...setCommands, ...inCommands]; + // Old plate OUT/Stop first, then set/start the single current countdown, then IN. + const commands = [...outCommands, ...stopCommands, ...setCommands, ...runCommands, ...inCommands]; if (commands.length) await sendRuntimeVmixSequence(commands); return commands.length; } @@ -4986,6 +5068,7 @@ function startCustomTooltips() { const action = configuredCommand === "toggle" ? (gameTimerState?.running ? "pause" : "start") : configuredCommand; const pausing = action === "pause"; const commands = []; + state.activeHockeyVmixTimerSteps.add(step.id); if (step.sync_vmix_game && step.game_vmix_input) { 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); commands.push({ Function: "SetText", Input: step.game_vmix_input, SelectedName: step.game_vmix_selected_name, Value: formatTimerValue(gameTimer, gameTimerState) }); } 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 { 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; if (!target?.input) return; 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") { - 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 }); 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) }); - } else if (pausing) { - commands.push({ Function: "SuspendCountdown", Input: target.input }); } else { - commands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: vmixCountdownValue(event.remainingMs) }); - commands.push({ Function: "StartCountdown", Input: target.input }); + state.vmixPenaltyTargetAssignments.set(penaltyTargetAssignmentKey(step, side, target), { + 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 }; 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"}» не найден`); } if (step.game_vmix_mode === "text" && gameTimer && gameTimerState) { @@ -5041,7 +5133,7 @@ function startCustomTooltips() { } } 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) { for (const { component, event } of penalties) { @@ -5532,7 +5624,10 @@ function applyExternalDataPatch(patch, { render = true } = {}) { ...detail }); 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(() => {}); + } 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") { fireConfiguredTimerFinishActions("game", { gameActionId: component.action_id, component, timerState }); @@ -5632,7 +5727,7 @@ function applyExternalDataPatch(patch, { render = true } = {}) { requestAnimationFrame(timerEngineFrame); } - function controlTimer(actionId, command = "toggle", rawValue = "") { + function controlTimer(actionId, command = "toggle", rawValue = "", options = {}) { const component = componentByActionId(actionId); if (!component || !isTimerComponent(component)) return false; const timerState = ensureTimerState(component); @@ -5718,7 +5813,7 @@ function applyExternalDataPatch(patch, { render = true } = {}) { timerState.lastTimestamp = now; persistTimer(component, timerState, true); - emitTimerEvent(component, eventName, timerState, { command, amount: rawValue }); + emitTimerEvent(component, eventName, timerState, { command, amount: rawValue, suppressVmixSync: options.syncVmix === false }); return true; } @@ -6741,7 +6836,7 @@ function openTimerQuickEditor(focusActionId = "") { board.selectedPreset = null; } - function controlHockeyPenalty(component, eventId, command, rawValue = "") { + function controlHockeyPenalty(component, eventId, command, rawValue = "", options = {}) { const board = ensureHockeyBoardState(component); const event = board.penalties.find((item) => item.id === eventId); if (!event) return false; @@ -6860,6 +6955,10 @@ function openTimerQuickEditor(focusActionId = "") { persistHockeyBoard(component, board, true); 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; } @@ -13327,10 +13426,10 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
Режим Text mirror рекомендуется: веб-таймер является источником истины и раз в секунду отправляет SetText строго в выбранные Input + SelectedName. Режим Countdown оставлен для титров, где countdown уже настроен внутри vMix. Для верхнего счёта по умолчанию используется одно ближайшее к окончанию удаление. При сложных/обоюдных удалениях, пока штрафы есть у обеих команд, HOME/AWAY остаются на своих сторонах и режим «играют в большинстве» не включается. Только когда одна сторона полностью очистится, оставшийся таймер переезжает на Input противоположной команды — стороны большинства. Если обе стороны очистились одновременно, дополнительные плашки просто снимаются. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.
Режим Countdown vMix рекомендуется и используется по умолчанию: веб отправляет SetCountdown только при установке/коррекции времени и затем StartCountdown; каждую секунду значение больше не передаётся. Text mirror оставлен только как режим совместимости для старых титров. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывается на стороне команды преимущества и отсчитывает ближайшее изменение численного состава; при чистом равном обоюдном удалении плашка не выводится. Режим «Все удаления по слотам» оставлен как дополнительный. Действие по окончании показывает выбранный Input в заданном Overlay и автоматически убирает его через указанное время.