This commit is contained in:
2026-08-24 17:22:02 +03:00
parent bd22229430
commit f1605470ff
4 changed files with 75 additions and 23 deletions

2
app.py
View File

@@ -29,7 +29,7 @@ from ui_builder import install_ui_builder
from khl_site.khl_data_center import APP as khl_site_app
BASE_DIR = Path(__file__).resolve().parent
BUILD_VERSION = "2026.08.24.9"
BUILD_VERSION = "2026.08.24.10"
# compatibility: BUILD_VERSION = "2026.08.24.8"
# compatibility: BUILD_VERSION = "2026.08.24.7"
# compatibility: BUILD_VERSION = "2026.08.24.6"

View File

@@ -1539,7 +1539,7 @@ class VmixAgentHub:
if no_ack_delivery:
# BUILD106: realtime operator traffic bypasses Mapping's ACK queue. The new
# timer-fast-ordered mode deliberately sends each timer command as its own WebSocket
# frame in strict order (Stop -> Set -> Start) and never waits for Agent/vMix ACK.
# frame in strict caller order and never waits for Agent/vMix ACK.
# Legacy timer-fast keeps vmix.batch compatibility. The shared realtime lock prevents
# interleaving with F-key traffic without reintroducing the slow Mapping lock.
async with self._device_vmix_shortcut_send_lock(target_device_id):

View File

@@ -0,0 +1,46 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
APP_JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8")
APP_PY = (ROOT / "app.py").read_text(encoding="utf-8")
def helper_block():
start = APP_JS.index("function vmixCountdownSyncCommands")
end = APP_JS.index("function buildShortcutRuntimeContext", start)
return APP_JS[start:end]
def test_pause_uses_pause_and_change_without_stop_reset():
block = helper_block()
pause_start = block.index('if (action === "pause" || action === "stop")')
set_start = block.index('if (action === "set")', pause_start)
pause_branch = block[pause_start:set_start]
assert 'Function: "PauseCountdown"' in pause_branch
assert 'Function: "ChangeCountdown"' in pause_branch
assert 'Function: "StopCountdown"' not in pause_branch
assert pause_branch.index('Function: "PauseCountdown"') < pause_branch.index('Function: "ChangeCountdown"')
def test_start_uses_change_then_start_without_stop_or_set_duration():
block = helper_block()
start_branch = block[block.index("// Start/Resume:"):]
assert 'Function: "ChangeCountdown"' in start_branch
assert 'Function: "StartCountdown"' in start_branch
assert 'Function: "StopCountdown"' not in start_branch
assert 'Function: "SetCountdown"' not in start_branch
assert start_branch.index('Function: "ChangeCountdown"') < start_branch.index('Function: "StartCountdown"')
def test_set_only_changes_current_position_and_does_not_toggle_pause():
block = helper_block()
set_start = block.index('if (action === "set")')
start_branch = block.index("// Start/Resume:", set_start)
set_branch = block[set_start:start_branch]
assert 'Function: "ChangeCountdown"' in set_branch
assert 'Function: "PauseCountdown"' not in set_branch
assert 'Function: "StopCountdown"' not in set_branch
def test_runtime_version():
assert 'BUILD_VERSION = "2026.08.24.10"' in APP_PY

View File

@@ -4153,12 +4153,10 @@ function startCustomTooltips() {
}
function vmixCountdownSyncCommands(input, selectedName, millisecondsProvider, action = "start") {
// BUILD106: Runtime is the ONLY source of truth for game/penalty time.
// Important vMix detail: SetCountdown changes the countdown Duration; it does not
// 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.
// BUILD107: Runtime remains the source of truth, but normal operator control no
// longer uses StopCountdown because vMix defines it as Stop + Reset.
// ChangeCountdown updates the CURRENT countdown position; SetCountdown only changes
// Duration. PauseCountdown is used only for an actual Runtime pause transition.
const target = { Input: String(input || "").trim(), SelectedName: String(selectedName || "").trim() };
const renderTarget = { Input: target.Input };
const currentValue = () => {
@@ -4167,24 +4165,32 @@ function startCustomTooltips() {
};
if (!target.Input || !target.SelectedName) return [];
const reseedStopped = [
{ Function: "PauseRender", ...renderTarget },
{ Function: "StopCountdown", ...target },
{ 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;
if (action === "pause" || action === "stop") {
// Space pause: toggle the native countdown into Pause, then pin its current value
// to the exact Runtime time. No StopCountdown reset is allowed in this path.
return [
{ Function: "PauseRender", ...renderTarget },
{ Function: "PauseCountdown", ...target },
{ Function: "ChangeCountdown", ...target, Value: currentValue },
{ Function: "ResumeRender", ...renderTarget },
];
}
if (action === "set") {
// Scoreboard/F1 pre-sync while Runtime is not running: update only the current
// vMix position. Do not toggle PauseCountdown because it is a Pause/Resume toggle.
return [
{ Function: "PauseRender", ...renderTarget },
{ Function: "ChangeCountdown", ...target, Value: currentValue },
{ Function: "ResumeRender", ...renderTarget },
];
}
// Start/Resume: set the native CURRENT position from Runtime and then run it.
// This avoids both StopCountdown reset and SetCountdown duration-only semantics.
return [
{ Function: "PauseRender", ...renderTarget },
{ Function: "StopCountdown", ...target },
{ Function: "SetCountdown", ...target, Value: currentValue },
{ Function: "ChangeCountdown", ...target, Value: currentValue },
{ Function: "ResumeRender", ...renderTarget },
{ Function: "StartCountdown", ...target },
];
@@ -13915,7 +13921,7 @@ function renderHockeyPenaltyDashboard(node, component, runtime) {
<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>
<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>
<p class="shortcut-step-note">Режим <b>Countdown vMix</b>: веб-таймер является источником времени. Перед Start/Resume vMix выполняет <code>ChangeCountdown(время Runtime) → StartCountdown</code>; при Pause/Stop выполняет <code>PauseCountdown → ChangeCountdown(время Runtime)</code>. <code>StopCountdown</code> в обычном управлении не используется, потому что он сбрасывает countdown. Команды идут в Agent без ACK отдельными кадрами строго по порядку. Каждую секунду значение в vMix не передаётся. <b>Text mirror</b> оставлен только для совместимости со старыми титрами. Для верхнего счёта используется одна penalty-плашка: при реальном большинстве она показывает ближайшее изменение численного состава; при чистом равном обоюдном удалении сама по себе не появляется.</p>
</div>`;
} 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>`;