шорткаты
This commit is contained in:
@@ -249,6 +249,10 @@ class VmixAgentHub:
|
||||
# BUILD97: serialize Mapping applications per Agent while still letting
|
||||
# runtime/timer commands use the normal vMix FIFO between Mapping chunks.
|
||||
self._mapping_apply_locks: dict[str, asyncio.Lock] = {}
|
||||
# BUILD102: interactive operator shortcuts use their own tiny send lock.
|
||||
# They must never sit behind Mapping's ACK wait, otherwise a visible F-key
|
||||
# or bottom-dock button can feel dead even though vMix itself is reachable.
|
||||
self._vmix_shortcut_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] = {}
|
||||
@@ -688,6 +692,13 @@ class VmixAgentHub:
|
||||
self._mapping_apply_locks[device_id] = lock
|
||||
return lock
|
||||
|
||||
def _device_vmix_shortcut_send_lock(self, device_id: str) -> asyncio.Lock:
|
||||
lock = self._vmix_shortcut_send_locks.get(device_id)
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
self._vmix_shortcut_send_locks[device_id] = lock
|
||||
return lock
|
||||
|
||||
@staticmethod
|
||||
def _mapping_batch_chunks(entries: list[dict[str, Any]]) -> list[list[tuple[int, dict[str, Any]]]]:
|
||||
"""Group Mapping commands by vMix Input, then cap packet count and bytes.
|
||||
@@ -1505,72 +1516,46 @@ class VmixAgentHub:
|
||||
prepared_commands.append(command)
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
# 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.
|
||||
# BUILD102: operator Shortcut/Quick-panel traffic is fire-and-forget after
|
||||
# the WebSocket frame has been handed to the connected Agent. We deliberately
|
||||
# do NOT wait for command.ack here. Agent ACK can arrive later and is ignored
|
||||
# for this transport. Mapping and generic runtime sync keep their existing ACK
|
||||
# semantics so diagnostics are still useful where exact field delivery matters.
|
||||
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")
|
||||
transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy")
|
||||
|
||||
async with self._device_vmix_send_lock(target_device_id):
|
||||
if use_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)),
|
||||
)
|
||||
ack_results = ack.get("results") if isinstance(ack.get("results"), list) else []
|
||||
if interactive_shortcut:
|
||||
# Preserve order between operator shortcuts, but do not wait behind Mapping's
|
||||
# command ACK lock. A shortcut returns as soon as its frames are on the Agent socket.
|
||||
async with self._device_vmix_shortcut_send_lock(target_device_id):
|
||||
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"))
|
||||
request_id = secrets.token_urlsafe(12)
|
||||
delivered = await self.send(
|
||||
target_device_id,
|
||||
{
|
||||
"type": "vmix.command",
|
||||
"protocol": AGENT_PROTOCOL_VERSION,
|
||||
"request_id": request_id,
|
||||
"device_id": target_device_id,
|
||||
"assignment_id": assignment_id,
|
||||
"match_id": match_id,
|
||||
"command": command,
|
||||
},
|
||||
)
|
||||
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")),
|
||||
"ok": bool(delivered),
|
||||
"reason": "" if delivered else "Agent сейчас offline",
|
||||
"delivery": "agent-websocket",
|
||||
"ack_waited": False,
|
||||
}
|
||||
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
|
||||
)
|
||||
else:
|
||||
for index, command in enumerate(prepared_commands):
|
||||
function = str(command.get("Function") or "")
|
||||
try:
|
||||
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 ""),
|
||||
}
|
||||
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"]:
|
||||
if delivered:
|
||||
# With no ACK wait the local ON AIR state is intentionally optimistic:
|
||||
# success means delivery to the live Agent WebSocket, not vMix confirmation.
|
||||
self._track_runtime_overlay_command(
|
||||
target_device_id,
|
||||
command,
|
||||
@@ -1578,10 +1563,66 @@ class VmixAgentHub:
|
||||
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:
|
||||
else:
|
||||
break
|
||||
else:
|
||||
async with self._device_vmix_send_lock(target_device_id):
|
||||
if use_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)),
|
||||
)
|
||||
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)
|
||||
else:
|
||||
for index, command in enumerate(prepared_commands):
|
||||
function = str(command.get("Function") or "")
|
||||
try:
|
||||
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 ""),
|
||||
}
|
||||
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)
|
||||
if not item["ok"]:
|
||||
break
|
||||
|
||||
failed = [item for item in results if not item["ok"]]
|
||||
return {
|
||||
@@ -1596,6 +1637,7 @@ class VmixAgentHub:
|
||||
"failed": len(failed),
|
||||
"results": results,
|
||||
"transport": transport,
|
||||
"confirmation": "not_waited" if interactive_shortcut else "ack",
|
||||
"overlay_state": self._runtime_overlay_payload(target_device_id),
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user