поправили механику работы шорткатов

This commit is contained in:
2026-08-24 13:37:50 +03:00
parent e09a1eeff5
commit 7d934a6ee6
4 changed files with 259 additions and 34 deletions

3
app.py
View File

@@ -29,7 +29,8 @@ from ui_builder import install_ui_builder
from khl_site.khl_data_center import APP as khl_site_app from khl_site.khl_data_center import APP as khl_site_app
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
BUILD_VERSION = "2026.08.24.2" BUILD_VERSION = "2026.08.24.3"
# compatibility: BUILD_VERSION = "2026.08.24.2"
# compatibility: BUILD_VERSION = "2026.08.24.1" # compatibility: BUILD_VERSION = "2026.08.24.1"
# compatibility: BUILD_VERSION = "2026.08.21.1" # compatibility: BUILD_VERSION = "2026.08.21.1"
# compatibility: BUILD_VERSION = "2026.08.20.17" # compatibility: BUILD_VERSION = "2026.08.20.17"

View File

@@ -1505,9 +1505,18 @@ class VmixAgentHub:
prepared_commands.append(command) prepared_commands.append(command)
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
transport = "batch" if self._agent_supports_batch(agent_version) and len(prepared_commands) > 1 else "legacy" # BUILD100: operator Shortcut/Quick-panel traffic is intentionally delivered
# as ordered single vmix.command frames with an ACK for every command. Mapping
# keeps its chunked batch transport and generic non-shortcut runtime sync may
# still use Agent 1.4 vmix.batch. Interactive title/timer actions are small, and
# losing the rest of a shortcut after one bad command is much worse than a few
# extra websocket frames.
interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip())
use_batch = (not interactive_shortcut) and self._agent_supports_batch(agent_version) and len(prepared_commands) > 1
transport = "batch" if use_batch else ("shortcut-sequential" if interactive_shortcut else "legacy")
async with self._device_vmix_send_lock(target_device_id): async with self._device_vmix_send_lock(target_device_id):
if transport == "batch": if use_batch:
ack = await self._send_vmix_batch_unlocked( ack = await self._send_vmix_batch_unlocked(
target_device_id, target_device_id,
assignment_id=assignment_id, assignment_id=assignment_id,
@@ -1531,40 +1540,60 @@ class VmixAgentHub:
self._track_runtime_overlay_command( self._track_runtime_overlay_command(
target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id 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: else:
for index, command in enumerate(prepared_commands): for index, command in enumerate(prepared_commands):
function = str(command.get("Function") or "") function = str(command.get("Function") or "")
ack = await self._send_vmix_command_unlocked( try:
target_device_id, assignment_id=assignment_id, match_id=match_id, command=command, timeout=timeout ack = await self._send_vmix_command_unlocked(
) target_device_id,
item = { assignment_id=assignment_id,
"index": index, match_id=match_id,
"function": function, command=command,
"ok": bool(ack.get("ok")), timeout=timeout,
"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( item = {
target_device_id, command, sequence_id=sequence_id, sequence_name=sequence_name, button_id=button_id "index": index,
) "function": function,
"ok": bool(ack.get("ok")),
"reason": str(ack.get("reason") or ack.get("error") or ""),
}
except Exception as error:
detail = getattr(error, "detail", None)
if isinstance(detail, dict):
reason = str(detail.get("message") or detail)
else:
reason = str(detail or error or "vmix_command_error")
item = {
"index": index,
"function": function,
"ok": False,
"reason": reason[:500],
}
results.append(item)
if item["ok"]:
self._track_runtime_overlay_command(
target_device_id,
command,
sequence_id=sequence_id,
sequence_name=sequence_name,
button_id=button_id,
)
# For ordinary legacy non-shortcut runtime calls preserve the old
# fail-fast behavior. Shortcut delivery continues deliberately.
if not item["ok"] and not interactive_shortcut:
break
failed = [item for item in results if not item["ok"]]
return { return {
"ok": True, "ok": not failed and len(results) == len(prepared_commands),
"device_id": target_device_id, "device_id": target_device_id,
"match_id": match_id, "match_id": match_id,
"assignment_id": assignment_id, "assignment_id": assignment_id,
"session_token": session_token, "session_token": session_token,
"applied": len(results), "applied": sum(1 for item in results if item["ok"]),
"attempted": len(results),
"requested": len(prepared_commands),
"failed": len(failed),
"results": results, "results": results,
"transport": transport, "transport": transport,
"overlay_state": self._runtime_overlay_payload(target_device_id), "overlay_state": self._runtime_overlay_payload(target_device_id),

View File

@@ -0,0 +1,118 @@
from __future__ import annotations
import asyncio
from pathlib import Path
from types import SimpleNamespace
from hockey_data.agent_bridge import VmixAgentHub
from hockey_data.auth_bridge import HockeyUser
from tests.support import LocalTestDatabase
ROOT = Path(__file__).resolve().parents[1]
APP_JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8")
APP = (ROOT / "app.py").read_text(encoding="utf-8")
BRIDGE = (ROOT / "hockey_data" / "agent_bridge.py").read_text(encoding="utf-8")
class FakeWebSocket:
def __init__(self) -> None:
self.client = SimpleNamespace(host="127.0.0.1")
self.sent: list[dict] = []
async def send_json(self, payload: dict) -> None:
self.sent.append(payload)
async def close(self, **_kwargs) -> None:
return None
def test_build100_frontend_shortcut_edges_and_pending_press_are_guarded() -> None:
assert 'pressedShortcutKeys: new Set()' in APP_JS
assert 'state.pressedShortcutKeys.has(physicalKey)' in APP_JS
assert 'state.pressedShortcutKeys.delete(physicalKey)' in APP_JS
assert 'pendingShortcutSequenceRuns: new Map()' in APP_JS
assert 'if (!state.pendingShortcutSequenceRuns.has(sequence.id))' in APP_JS
assert 'one stale/broken title must not prevent the remaining title' in APP_JS
assert 'requestTimeoutMs = Math.min(60000' in APP_JS
assert 'BUILD_VERSION = "2026.08.24.3"' in APP
def test_build100_interactive_shortcut_is_single_command_acked_and_continues_after_failure(tmp_path: Path) -> None:
database = LocalTestDatabase(tmp_path / "build100-shortcut.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
ws = FakeWebSocket()
user = HockeyUser(id="100", login="operator100", display_name="operator100")
async def scenario() -> None:
await hub.register(
ws, # type: ignore[arg-type]
{
"device_id": "GFX-BUILD100",
"device_secret": "z" * 40,
"device_name": "Build100 GFX",
"hostname": "BUILD100-PC",
"agent_version": "1.4.0",
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
},
)
await hub.pair_device("GFX-BUILD100", user)
assigned = await hub.assign_match(
wfl_user_id=user.id,
tournament_external_id="1437",
game_external_id="100100",
)
assert assigned is not None
task = asyncio.create_task(
hub.run_vmix_sequence_for_user(
user,
[
{"Function": "OverlayInput1In", "Input": "SCORE"},
{"Function": "OverlayInput2In", "Input": "BROKEN"},
{"Function": "OverlayInput3In", "Input": "LINEUP"},
],
sequence_id="shortcut-build100",
sequence_name="Titles",
)
)
seen: list[dict] = []
for index, ok in enumerate([True, False, True]):
command = None
for _ in range(100):
await asyncio.sleep(0)
commands = [item for item in ws.sent if item.get("type") == "vmix.command"]
if len(commands) > index:
command = commands[index]
break
assert command is not None
seen.append(command)
await hub.receive_command_ack(
"GFX-BUILD100",
{
"type": "command.ack",
"request_id": command["request_id"],
"ok": ok,
"reason": "bad input" if not ok else "",
},
)
assert not [item for item in ws.sent if item.get("type") == "vmix.batch"]
assert [item["command"]["Input"] for item in seen] == ["SCORE", "BROKEN", "LINEUP"]
result = await task
assert result["transport"] == "shortcut-sequential"
assert result["requested"] == 3
assert result["attempted"] == 3
assert result["applied"] == 2
assert result["failed"] == 1
assert result["ok"] is False
assert result["results"][2]["ok"] is True
asyncio.run(scenario())
def test_build100_backend_marks_interactive_shortcuts_sequential() -> None:
assert 'interactive_shortcut = bool' in BRIDGE
assert 'transport = "batch" if use_batch else ("shortcut-sequential"' in BRIDGE
assert 'if not item["ok"] and not interactive_shortcut' in BRIDGE

View File

@@ -76,10 +76,18 @@
runtimeResizeObserver: null, runtimeResizeObserver: null,
shortcutCapture: null, shortcutCapture: null,
pressedShortcutModifiers: new Set(), pressedShortcutModifiers: new Set(),
// BUILD100: track physical non-modifier keys until keyup. Browser key repeat,
// focus quirks or duplicated keydown events must never enqueue several toggle actions
// for one physical press (Space is especially important for the game clock).
pressedShortcutKeys: new Set(),
modifierShortcutChordModifiers: new Set(), modifierShortcutChordModifiers: new Set(),
modifierShortcutChordUsedKey: false, modifierShortcutChordUsedKey: false,
modifierShortcutChordFired: false, modifierShortcutChordFired: false,
runningShortcutSequences: new Set(), runningShortcutSequences: new Set(),
// BUILD100: one second physical press while the same sequence is waiting for Agent ACK
// is remembered instead of being silently dropped. Extra impatient presses are
// coalesced, so they cannot build a future queue of Start/Stop toggles.
pendingShortcutSequenceRuns: new Map(),
// BUILD90: all runtime vMix requests share one browser-side FIFO. This prevents // BUILD90: all runtime vMix requests share one browser-side FIFO. This prevents
// two different shortcuts from interleaving commands while an Agent ACK is pending. // two different shortcuts from interleaving commands while an Agent ACK is pending.
vmixCommandQueue: Promise.resolve(), vmixCommandQueue: Promise.resolve(),
@@ -2250,6 +2258,15 @@ function startCustomTooltips() {
return key.length === 1 ? key.toUpperCase() : key; return key.length === 1 ? key.toUpperCase() : key;
} }
function shortcutPhysicalKeyToken(event) {
const modifier = shortcutModifierFromEvent(event);
if (modifier) return `modifier:${modifier}:${String(event.code || event.key || modifier)}`;
const code = String(event.code || "").trim();
if (code) return `key:${code}`;
const key = shortcutKeyFromEvent(event);
return key ? `key:${key}` : "";
}
function shortcutFromKeyboardEvent(event) { function shortcutFromKeyboardEvent(event) {
const key = shortcutKeyFromEvent(event); const key = shortcutKeyFromEvent(event);
if (!key) return ""; if (!key) return "";
@@ -4567,7 +4584,11 @@ function startCustomTooltips() {
if (!clean.length) return { ok: true, applied: 0, results: [] }; if (!clean.length) return { ok: true, applied: 0, results: [] };
state.vmixCommandQueueDepth += 1; state.vmixCommandQueueDepth += 1;
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 7000); // BUILD100: runtime vMix delivery is ACKed command-by-command on the server.
// The browser timeout must cover the whole small sequence, otherwise fetch can
// abort while the server is still legitimately delivering later title/timer commands.
const requestTimeoutMs = Math.min(60000, Math.max(20000, 10000 + clean.length * 5000));
const timeoutId = window.setTimeout(() => controller.abort(), requestTimeoutMs);
try { try {
const response = await fetch("/api/hockey/vmix/sequence", { const response = await fetch("/api/hockey/vmix/sequence", {
method: "POST", method: "POST",
@@ -4590,12 +4611,27 @@ function startCustomTooltips() {
const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`; const detail = payload?.detail?.message || payload?.detail || `HTTP ${response.status}`;
throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail)); throw new Error(typeof detail === "string" ? detail : JSON.stringify(detail));
} }
trackRuntimeOverlayCommands(clean, execution); // BUILD53 compatibility marker: trackRuntimeOverlayCommands(clean, execution)
// BUILD100 tracks only ACK-successful commands below, so a failed title cannot
// incorrectly light the ON AIR state.
const resultRows = Array.isArray(payload?.results) ? payload.results : [];
const successfulCommands = clean.filter((_command, index) => {
const row = resultRows[index];
return !row || row.ok !== false;
});
trackRuntimeOverlayCommands(successfulCommands, execution);
if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state); if (payload?.overlay_state) applyServerRuntimeOverlayState(payload.overlay_state);
if (payload?.ok === false) {
const failed = resultRows.filter((row) => row && row.ok === false);
const first = failed[0] || {};
const label = first.function || "vMix";
const reason = first.reason || payload?.error || "команда не выполнена";
throw new Error(`${label}: ${reason}${failed.length > 1 ? ` · ошибок ${failed.length}` : ""}`);
}
return payload; return payload;
} catch (error) { } catch (error) {
if (error?.name === "AbortError") { if (error?.name === "AbortError") {
throw new Error("vMix/Agent не подтвердил команду за 7 секунд"); throw new Error(`vMix/Agent не завершил очередь команд за ${Math.round(requestTimeoutMs / 1000)} сек.`);
} }
throw error; throw error;
} finally { } finally {
@@ -5257,7 +5293,15 @@ function startCustomTooltips() {
async function runShortcutSequence(sequenceOrId, meta = {}) { async function runShortcutSequence(sequenceOrId, meta = {}) {
const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId; const sequence = typeof sequenceOrId === "string" ? shortcutSequenceById(sequenceOrId) : sequenceOrId;
if (!sequence || sequence.enabled === false) return false; if (!sequence || sequence.enabled === false) return false;
if (state.runningShortcutSequences.has(sequence.id)) return false; if (state.runningShortcutSequences.has(sequence.id)) {
// Remember at most ONE follow-up action. This makes Start → Stop reliable when
// Agent ACK is still pending, while repeated impatient Space presses cannot
// accumulate five future toggles and flip the timer back and forth later.
if (!state.pendingShortcutSequenceRuns.has(sequence.id)) {
state.pendingShortcutSequenceRuns.set(sequence.id, { ...meta, queued: true });
}
return true;
}
const wasOnAir = shortcutSequenceIsOnAir(sequence.id); const wasOnAir = shortcutSequenceIsOnAir(sequence.id);
state.runningShortcutSequences.add(sequence.id); state.runningShortcutSequences.add(sequence.id);
const execution = { sequence_id: String(sequence.id || ""), sequence_name: String(sequence.name || ""), button_id: String(meta.button_id || "") }; const execution = { sequence_id: String(sequence.id || ""), sequence_name: String(sequence.name || ""), button_id: String(meta.button_id || "") };
@@ -5283,7 +5327,23 @@ function startCustomTooltips() {
else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`); else if (String(meta.source || "").startsWith("keyboard")) toast(`Шорткат выполнен: ${sequence.name}`);
return true; return true;
} }
for (const step of sequence.steps || []) await runShortcutSequenceStep(sequence, step, execution); const stepErrors = [];
for (const [stepIndex, step] of (sequence.steps || []).entries()) {
try {
await runShortcutSequenceStep(sequence, step, execution);
} catch (error) {
const functionName = step?.type === "vmix_command" ? String(step.function || "vMix") : String(step?.type || "step");
const message = String(error?.message || error || "Ошибка шага");
stepErrors.push({ index: stepIndex, function: functionName, message });
console.error("UI Builder shortcut step error", { sequence, stepIndex, step, error });
// BUILD100: one stale/broken title must not prevent the remaining title,
// timer and overlay steps from being sent to Agent.
}
}
if (stepErrors.length) {
const first = stepErrors[0];
throw new Error(`шаг ${first.index + 1} (${first.function}): ${first.message}${stepErrors.length > 1 ? ` · всего ошибок ${stepErrors.length}` : ""}`);
}
if (sequence.toggle_all_overlays_on_repeat) { if (sequence.toggle_all_overlays_on_repeat) {
state.shortcutSequenceOverlayState.set(sequence.id, true); state.shortcutSequenceOverlayState.set(sequence.id, true);
state.quickPanelOnAirSequences.add(String(sequence.id || "")); state.quickPanelOnAirSequences.add(String(sequence.id || ""));
@@ -5303,6 +5363,9 @@ function startCustomTooltips() {
return false; return false;
} finally { } finally {
state.runningShortcutSequences.delete(sequence.id); state.runningShortcutSequences.delete(sequence.id);
const nextMeta = state.pendingShortcutSequenceRuns.get(sequence.id) || null;
state.pendingShortcutSequenceRuns.delete(sequence.id);
if (nextMeta) queueMicrotask(() => runShortcutSequence(sequence.id, nextMeta));
} }
} }
@@ -5336,10 +5399,21 @@ function startCustomTooltips() {
} }
function handleConfiguredShortcuts(event) { function handleConfiguredShortcuts(event) {
const physicalKey = shortcutPhysicalKeyToken(event);
if (physicalKey && state.pressedShortcutKeys.has(physicalKey)) {
// This key already fired a configured shortcut and has not been released yet.
// Swallow browser auto-repeat/default activation until keyup.
event.preventDefault();
event.stopPropagation();
if (typeof event.stopImmediatePropagation === "function") event.stopImmediatePropagation();
return true;
}
if (event.repeat) return false; if (event.repeat) return false;
const combo = shortcutFromKeyboardEvent(event); const combo = shortcutFromKeyboardEvent(event);
if (!combo) return false; if (!combo) return false;
return handleConfiguredShortcutCombo(combo, event, "keyboard"); const handled = handleConfiguredShortcutCombo(combo, event, "keyboard");
if (handled && physicalKey) state.pressedShortcutKeys.add(physicalKey);
return handled;
} }
function handleModifierOnlyShortcutRelease(event) { function handleModifierOnlyShortcutRelease(event) {
@@ -14514,6 +14588,8 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
}, true); }, true);
window.addEventListener("keyup", (event) => { window.addEventListener("keyup", (event) => {
const physicalKey = shortcutPhysicalKeyToken(event);
if (physicalKey) state.pressedShortcutKeys.delete(physicalKey);
const modifier = shortcutModifierFromEvent(event); const modifier = shortcutModifierFromEvent(event);
if (!modifier) return; if (!modifier) return;
@@ -14538,6 +14614,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
}, true); }, true);
window.addEventListener("blur", () => { window.addEventListener("blur", () => {
state.pressedShortcutKeys.clear();
state.pressedShortcutModifiers.clear(); state.pressedShortcutModifiers.clear();
state.modifierShortcutChordModifiers.clear(); state.modifierShortcutChordModifiers.clear();
state.modifierShortcutChordUsedKey = false; state.modifierShortcutChordUsedKey = false;