test 4
This commit is contained in:
3
app.py
3
app.py
@@ -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.8"
|
BUILD_VERSION = "2026.08.24.9"
|
||||||
|
# compatibility: BUILD_VERSION = "2026.08.24.8"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.7"
|
# compatibility: BUILD_VERSION = "2026.08.24.7"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.6"
|
# compatibility: BUILD_VERSION = "2026.08.24.6"
|
||||||
# compatibility: BUILD_VERSION = "2026.08.24.5"
|
# compatibility: BUILD_VERSION = "2026.08.24.5"
|
||||||
|
|||||||
@@ -1524,23 +1524,26 @@ class VmixAgentHub:
|
|||||||
# semantics so diagnostics are still useful where exact field delivery matters.
|
# 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())
|
interactive_shortcut = bool(str(sequence_id or "").strip() or str(button_id or "").strip())
|
||||||
delivery_mode = str(delivery_mode or "").strip().lower()
|
delivery_mode = str(delivery_mode or "").strip().lower()
|
||||||
fast_timer = delivery_mode == "timer-fast"
|
fast_timer = delivery_mode in {"timer-fast", "timer-fast-ordered"}
|
||||||
|
ordered_timer = delivery_mode == "timer-fast-ordered"
|
||||||
no_ack_delivery = interactive_shortcut or fast_timer
|
no_ack_delivery = interactive_shortcut or fast_timer
|
||||||
supports_batch = self._agent_supports_batch(agent_version) and len(prepared_commands) > 1
|
supports_batch = self._agent_supports_batch(agent_version) and len(prepared_commands) > 1
|
||||||
use_batch = (not no_ack_delivery) and supports_batch
|
use_batch = (not no_ack_delivery) and supports_batch
|
||||||
if fast_timer:
|
if ordered_timer:
|
||||||
|
transport = "timer-ordered-no-ack"
|
||||||
|
elif fast_timer:
|
||||||
transport = "timer-batch-no-ack" if supports_batch else "timer-no-ack"
|
transport = "timer-batch-no-ack" if supports_batch else "timer-no-ack"
|
||||||
else:
|
else:
|
||||||
transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy")
|
transport = "shortcut-no-ack" if interactive_shortcut else ("batch" if use_batch else "legacy")
|
||||||
|
|
||||||
if no_ack_delivery:
|
if no_ack_delivery:
|
||||||
# BUILD103: realtime operator traffic (shortcuts + timers) bypasses Mapping's
|
# BUILD106: realtime operator traffic bypasses Mapping's ACK queue. The new
|
||||||
# ACK queue. Timer pairs use ONE vmix.batch frame on Agent 1.4+ and return
|
# timer-fast-ordered mode deliberately sends each timer command as its own WebSocket
|
||||||
# immediately after WebSocket delivery; the later Agent ACK is intentionally ignored.
|
# frame in strict order (Stop -> Set -> Start) and never waits for Agent/vMix ACK.
|
||||||
# The shared realtime lock preserves exact frame order if Space and an F-key are
|
# Legacy timer-fast keeps vmix.batch compatibility. The shared realtime lock prevents
|
||||||
# pressed almost simultaneously without reintroducing the slow Mapping lock.
|
# interleaving with F-key traffic without reintroducing the slow Mapping lock.
|
||||||
async with self._device_vmix_shortcut_send_lock(target_device_id):
|
async with self._device_vmix_shortcut_send_lock(target_device_id):
|
||||||
if fast_timer and supports_batch:
|
if fast_timer and supports_batch and not ordered_timer:
|
||||||
request_id = secrets.token_urlsafe(12)
|
request_id = secrets.token_urlsafe(12)
|
||||||
delivered = await self.send(
|
delivered = await self.send(
|
||||||
target_device_id,
|
target_device_id,
|
||||||
|
|||||||
119
tests/test_build106_runtime_authoritative_timer.py
Normal file
119
tests/test_build106_runtime_authoritative_timer.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
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 _block(start: str, end: str) -> str:
|
||||||
|
return APP_JS.split(start, 1)[1].split(end, 1)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_countdown_start_hard_reseeds_before_start() -> None:
|
||||||
|
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||||
|
assert 'Function: "PauseRender"' in helper
|
||||||
|
assert 'Function: "StopCountdown"' in helper
|
||||||
|
assert 'Function: "SetCountdown"' in helper
|
||||||
|
assert 'Function: "ResumeRender"' in helper
|
||||||
|
assert 'Function: "StartCountdown"' in helper
|
||||||
|
assert helper.index('Function: "StopCountdown"') < helper.index('Function: "SetCountdown"')
|
||||||
|
assert helper.rindex('Function: "SetCountdown"') < helper.rindex('Function: "StartCountdown"')
|
||||||
|
|
||||||
|
|
||||||
|
def test_pause_is_reseeded_from_runtime_not_native_clock() -> None:
|
||||||
|
helper = _block("function vmixCountdownSyncCommands", "function buildShortcutRuntimeContext")
|
||||||
|
branch = helper.split('if (action === "stop" || action === "pause" || action === "set")', 1)[1].split('return reseedStopped;', 1)[0]
|
||||||
|
assert "Runtime value" in branch
|
||||||
|
reseed = helper.split("const reseedStopped = [", 1)[1].split("];", 1)[0]
|
||||||
|
assert 'Function: "StopCountdown"' in reseed
|
||||||
|
assert 'Function: "SetCountdown"' in reseed
|
||||||
|
assert 'Value: currentValue' in reseed
|
||||||
|
|
||||||
|
|
||||||
|
def test_scoreboard_show_syncs_timer_before_overlay_steps() -> None:
|
||||||
|
runner = _block("async function runShortcutSequence", "function handleConfiguredShortcutCombo")
|
||||||
|
assert "if (sequence.is_scoreboard_sequence)" in runner
|
||||||
|
assert runner.index("await syncConfiguredScoreboardCountdownsToRuntime()") < runner.index("const stepErrors = []")
|
||||||
|
helper = _block("async function syncConfiguredScoreboardCountdownsToRuntime", "function penaltyMirrorKey")
|
||||||
|
assert 'timerState.running ? "start" : "set"' in helper
|
||||||
|
assert "await sendRuntimeVmixTimerSequence(commands)" in helper
|
||||||
|
|
||||||
|
|
||||||
|
def test_ordered_timer_transport_sends_individual_frames_without_ack(tmp_path: Path) -> None:
|
||||||
|
database = LocalTestDatabase(tmp_path / "build106-ordered.sqlite3")
|
||||||
|
database.create_all()
|
||||||
|
hub = VmixAgentHub(database) # type: ignore[arg-type]
|
||||||
|
ws = FakeWebSocket()
|
||||||
|
user = HockeyUser(id="106", login="operator106", display_name="operator106")
|
||||||
|
|
||||||
|
async def scenario() -> None:
|
||||||
|
await hub.register(
|
||||||
|
ws, # type: ignore[arg-type]
|
||||||
|
{
|
||||||
|
"device_id": "GFX-TIMER-106",
|
||||||
|
"device_secret": "u" * 40,
|
||||||
|
"device_name": "Build106 Timer GFX",
|
||||||
|
"hostname": "BUILD106-PC",
|
||||||
|
"agent_version": "1.4.0",
|
||||||
|
"vmix": {"connected": True, "url": "http://127.0.0.1:8088/api/"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await hub.pair_device("GFX-TIMER-106", user)
|
||||||
|
await hub.assign_match(
|
||||||
|
wfl_user_id=user.id,
|
||||||
|
tournament_external_id="1437",
|
||||||
|
game_external_id="106106",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
hub.run_vmix_sequence_for_user(
|
||||||
|
user,
|
||||||
|
[
|
||||||
|
{"Function": "PauseRender", "Input": "SCORE"},
|
||||||
|
{"Function": "StopCountdown", "Input": "SCORE", "SelectedName": "TIME.Text"},
|
||||||
|
{"Function": "SetCountdown", "Input": "SCORE", "SelectedName": "TIME.Text", "Value": "00:16:01"},
|
||||||
|
{"Function": "ResumeRender", "Input": "SCORE"},
|
||||||
|
{"Function": "StartCountdown", "Input": "SCORE", "SelectedName": "TIME.Text"},
|
||||||
|
],
|
||||||
|
delivery_mode="timer-fast-ordered",
|
||||||
|
),
|
||||||
|
timeout=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
singles = [item for item in ws.sent if item.get("type") == "vmix.command"]
|
||||||
|
batches = [item for item in ws.sent if item.get("type") == "vmix.batch"]
|
||||||
|
assert not batches
|
||||||
|
assert [item["command"]["Function"] for item in singles] == [
|
||||||
|
"PauseRender", "StopCountdown", "SetCountdown", "ResumeRender", "StartCountdown"
|
||||||
|
]
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["transport"] == "timer-ordered-no-ack"
|
||||||
|
assert result["confirmation"] == "not_waited"
|
||||||
|
assert all(row["ack_waited"] is False for row in result["results"])
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_build106_version() -> None:
|
||||||
|
assert 'BUILD_VERSION = "2026.08.24.9"' in APP
|
||||||
|
assert 'delivery_mode: "timer-fast-ordered"' in APP_JS
|
||||||
@@ -4153,33 +4153,39 @@ function startCustomTooltips() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") {
|
function vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") {
|
||||||
// BUILD101: keep timer transport intentionally small and deterministic.
|
// BUILD106: Runtime is the ONLY source of truth for game/penalty time.
|
||||||
// Runtime is authoritative. vMix is only reseeded from the current Runtime
|
// Important vMix detail: SetCountdown changes the countdown Duration; it does not
|
||||||
// value when the operator changes state; no ACK is required to change the web timer.
|
// reliably replace the current position of a countdown that was previously started
|
||||||
|
// or suspended. Therefore every operator state change performs a hard reseed:
|
||||||
|
// freeze title rendering -> reset native countdown -> set Runtime value -> unfreeze.
|
||||||
|
// Start/Resume then starts from that freshly seeded Runtime value.
|
||||||
const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() };
|
const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() };
|
||||||
|
const renderTarget = { Input: target.Input };
|
||||||
const currentValue = () => {
|
const currentValue = () => {
|
||||||
const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider;
|
const milliseconds = typeof millisecondsProvider === "function" ? millisecondsProvider() : millisecondsProvider;
|
||||||
return vmixCountdownValue(milliseconds);
|
return vmixCountdownValue(milliseconds);
|
||||||
};
|
};
|
||||||
if (!target.Input || !target.SelectedName) return [];
|
if (!target.Input || !target.SelectedName) return [];
|
||||||
if (action === "stop" || action === "pause") {
|
|
||||||
// BUILD105: vMix StopCountdown means STOP + RESET TO BEGINNING. It must never
|
const reseedStopped = [
|
||||||
// be used for an operator pause/stop where the current sports time has to freeze.
|
{ Function: "PauseRender", ...renderTarget },
|
||||||
// SuspendCountdown is the vMix pause-only command and preserves the exact native
|
{ Function: "StopCountdown", ...target },
|
||||||
// countdown value until the next SetCountdown + StartCountdown.
|
|
||||||
return [
|
|
||||||
{ Function: "SuspendCountdown", ...target },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
if (action === "set") {
|
|
||||||
// Explicit edits/reset still need to write the Runtime value while stopped.
|
|
||||||
return [
|
|
||||||
{ Function: "StopCountdown", ...target },
|
|
||||||
{ Function: "SetCountdown", ...target, Value: currentValue },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{ Function: "SetCountdown", ...target, Value: currentValue },
|
{ Function: "SetCountdown", ...target, Value: currentValue },
|
||||||
|
{ Function: "ResumeRender", ...renderTarget },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (action === "stop" || action === "pause" || action === "set") {
|
||||||
|
// Do not trust the native vMix current position on Pause. Freeze at the exact
|
||||||
|
// Runtime value and leave the native countdown stopped. The next Resume will
|
||||||
|
// hard-reseed again before StartCountdown.
|
||||||
|
return reseedStopped;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{ Function: "PauseRender", ...renderTarget },
|
||||||
|
{ Function: "StopCountdown", ...target },
|
||||||
|
{ Function: "SetCountdown", ...target, Value: currentValue },
|
||||||
|
{ Function: "ResumeRender", ...renderTarget },
|
||||||
{ Function: "StartCountdown", ...target },
|
{ Function: "StartCountdown", ...target },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -4655,8 +4661,9 @@ function startCustomTooltips() {
|
|||||||
function sendRuntimeVmixTimerSequence(commands) {
|
function sendRuntimeVmixTimerSequence(commands) {
|
||||||
// BUILD103: timer transport is deliberately independent from the generic
|
// BUILD103: timer transport is deliberately independent from the generic
|
||||||
// shortcut/title/Mapping ACK queue. The web timer has already changed state before
|
// shortcut/title/Mapping ACK queue. The web timer has already changed state before
|
||||||
// this function is called. Server delivery_mode=timer-fast sends the tiny native
|
// this function is called. Server delivery_mode=timer-fast-ordered sends native Countdown commands to Agent
|
||||||
// Countdown pair to Agent immediately without waiting for ACK, preferably as one batch.
|
// immediately without ACK, but as separate ordered WebSocket frames. This avoids any
|
||||||
|
// ambiguity about command ordering inside Agent vmix.batch handling.
|
||||||
const rawCommands = typeof commands === "function" ? commands() : commands;
|
const rawCommands = typeof commands === "function" ? commands() : commands;
|
||||||
const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function);
|
const clean = (rawCommands || []).map(compactVmixCommand).filter((command) => command.Function);
|
||||||
if (!clean.length) return Promise.resolve({ ok: true, applied: 0, requested: 0, results: [] });
|
if (!clean.length) return Promise.resolve({ ok: true, applied: 0, requested: 0, results: [] });
|
||||||
@@ -4667,14 +4674,13 @@ function startCustomTooltips() {
|
|||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
// Intentionally omit sequence_id/button_id. Agent 1.4+ can then send the
|
// Intentionally omit sequence_id/button_id. Timer transport is fire-and-forget,
|
||||||
// tiny SetCountdown+Start/Stop pair as one vmix.batch instead of waiting
|
// but BUILD106 uses ordered single WebSocket frames rather than vmix.batch.
|
||||||
// for several interactive ACK round-trips.
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
commands: clean,
|
commands: clean,
|
||||||
device_id: currentRuntimeVmixDeviceId(),
|
device_id: currentRuntimeVmixDeviceId(),
|
||||||
session_token: currentRuntimeHockeySessionToken(),
|
session_token: currentRuntimeHockeySessionToken(),
|
||||||
delivery_mode: "timer-fast",
|
delivery_mode: "timer-fast-ordered",
|
||||||
}),
|
}),
|
||||||
}).then(async (response) => {
|
}).then(async (response) => {
|
||||||
let payload = {};
|
let payload = {};
|
||||||
@@ -4768,6 +4774,45 @@ function startCustomTooltips() {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function configuredHockeyVmixGameCountdownTargets() {
|
||||||
|
const targets = [];
|
||||||
|
const seen = new Set();
|
||||||
|
(state.config.shortcut_sequences || []).forEach((sequence) => {
|
||||||
|
if (sequence?.enabled === false) return;
|
||||||
|
(sequence.steps || []).forEach((step) => {
|
||||||
|
if (!step || step.enabled === false || step.type !== "hockey_vmix_timers_start") return;
|
||||||
|
if (!step.sync_vmix_game || step.game_vmix_mode !== "countdown") return;
|
||||||
|
const input = String(step.game_vmix_input || "").trim();
|
||||||
|
const selectedName = String(step.game_vmix_selected_name || "").trim();
|
||||||
|
if (!input || !selectedName) return;
|
||||||
|
const timer = componentByActionId(step.game_timer_action_id || "hockey_game_timer");
|
||||||
|
if (!timer || !isTimerComponent(timer)) return;
|
||||||
|
const key = `${input}\u0000${selectedName}`;
|
||||||
|
if (seen.has(key)) return;
|
||||||
|
seen.add(key);
|
||||||
|
targets.push({ input, selectedName, timer, timerState: ensureTimerState(timer) });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncConfiguredScoreboardCountdownsToRuntime() {
|
||||||
|
const commands = [];
|
||||||
|
configuredHockeyVmixGameCountdownTargets().forEach(({ input, selectedName, timerState }) => {
|
||||||
|
commands.push(...vmixCountdownSyncCommands(
|
||||||
|
input,
|
||||||
|
selectedName,
|
||||||
|
() => timerState.currentMs,
|
||||||
|
timerState.running ? "start" : "set"
|
||||||
|
));
|
||||||
|
});
|
||||||
|
if (!commands.length) return { ok: true, applied: 0, requested: 0 };
|
||||||
|
// Await only WebSocket dispatch (never vMix ACK) so the countdown reseed reaches the
|
||||||
|
// Agent before the scoreboard OverlayIn command. This fixes stale timer values when F1
|
||||||
|
// shows a scoreboard before Space has ever been pressed.
|
||||||
|
return await sendRuntimeVmixTimerSequence(commands);
|
||||||
|
}
|
||||||
|
|
||||||
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 || "")}`;
|
||||||
}
|
}
|
||||||
@@ -5078,26 +5123,21 @@ function startCustomTooltips() {
|
|||||||
&& (force || assignmentChanged || previous?.running !== true);
|
&& (force || assignmentChanged || previous?.running !== true);
|
||||||
if (entry.event.running) {
|
if (entry.event.running) {
|
||||||
if (force || assignmentChanged || startingCountdown) {
|
if (force || assignmentChanged || startingCountdown) {
|
||||||
// BUILD101: while the web penalty is running, only reseed then run.
|
// BUILD106: penalty countdowns follow the same Runtime-authoritative rule
|
||||||
// Do not inject an extra Stop before every Start; Runtime already owns
|
// as the game clock. Never continue an old native position: Stop first,
|
||||||
// the state and the short SetCountdown+Start pair is enough.
|
// then seed the exact web remaining time, then Start.
|
||||||
|
stopCommands.push({ Function: "StopCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||||
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) });
|
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) });
|
||||||
}
|
}
|
||||||
if (startingCountdown) {
|
if (startingCountdown) {
|
||||||
runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
|
runCommands.push({ Function: "StartCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||||
}
|
}
|
||||||
} else if (force || assignmentChanged || previous?.running !== false) {
|
} else if (force || assignmentChanged || previous?.running !== false) {
|
||||||
// BUILD105: an unchanged prepared/paused penalty must use SuspendCountdown.
|
// prepared/paused penalty: reseed from Runtime. preservePausedCountdown
|
||||||
// StopCountdown resets a native vMix countdown to its beginning. For a
|
// remains in the public call signature for compatibility, but native vMix time is
|
||||||
// reassigned/reset target we may still deliberately Stop + Set below.
|
// never trusted as the authoritative value anymore.
|
||||||
if (preservePausedCountdown && !assignmentChanged) {
|
stopCommands.push({ Function: "StopCountdown", Input: target.input, SelectedName: target.selected_name });
|
||||||
stopCommands.push({ Function: "SuspendCountdown", Input: target.input, SelectedName: target.selected_name });
|
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) });
|
||||||
} else {
|
|
||||||
stopCommands.push({ Function: "StopCountdown", Input: target.input, SelectedName: target.selected_name });
|
|
||||||
}
|
|
||||||
if (!preservePausedCountdown || assignmentChanged) {
|
|
||||||
setCommands.push({ Function: "SetCountdown", Input: target.input, SelectedName: target.selected_name, Value: () => vmixCountdownValue(entry.event.remainingMs) });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
assignedMirrorKeys.add(eventKey);
|
assignedMirrorKeys.add(eventKey);
|
||||||
@@ -5419,6 +5459,9 @@ 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;
|
||||||
}
|
}
|
||||||
|
if (sequence.is_scoreboard_sequence) {
|
||||||
|
await syncConfiguredScoreboardCountdownsToRuntime();
|
||||||
|
}
|
||||||
const stepErrors = [];
|
const stepErrors = [];
|
||||||
for (const [stepIndex, step] of (sequence.steps || []).entries()) {
|
for (const [stepIndex, step] of (sequence.steps || []).entries()) {
|
||||||
try {
|
try {
|
||||||
@@ -13872,7 +13915,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>Countdown vMix</b>: веб-таймер является главным. Start сразу меняет состояние Runtime и независимо отправляет <code>SetCountdown → StartCountdown</code> в vMix; Pause/Stop сразу останавливают Runtime и независимо отправляют только <code>SuspendCountdown</code> (пауза без сброса), без повторной установки времени. Ошибка Agent не блокирует управление таймером. Каждую секунду значение в vMix не передаётся. <b>Text mirror</b> оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.</p>
|
<p class="shortcut-step-note">Режим <b>Countdown vMix</b>: веб-таймер является единственным источником времени. Перед Start/Resume vMix выполняет <code>StopCountdown → SetCountdown(время Runtime) → StartCountdown</code>; при Pause/Stop vMix выполняет <code>StopCountdown → SetCountdown(время Runtime)</code> и остаётся остановленным на точном веб-времени. Команды идут в Agent без ACK отдельными кадрами строго по порядку. Каждую секунду значение в vMix не передаётся. <b>Text mirror</b> оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.</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>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user