1. поправлено работа с таймерами в vMix

2. поправлено немного Заготовки
This commit is contained in:
2026-08-24 12:44:45 +03:00
parent 17c3fcfbac
commit e09a1eeff5
11 changed files with 641 additions and 135 deletions

View File

@@ -2308,6 +2308,96 @@ class VmixAgentHub:
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
}
@staticmethod
def _prepared_saved_fields(row: VmixPreparedTitle) -> dict[str, dict[str, str]]:
try:
payload = json.loads(row.field_values_json or "{}")
except Exception:
payload = {}
if not isinstance(payload, dict):
return {}
result: dict[str, dict[str, str]] = {}
for raw_name, raw_entry in list(payload.items())[:500]:
name = str(raw_name or "").strip()[:300]
if not name:
continue
if isinstance(raw_entry, dict):
value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
field_type = str(raw_entry.get("type") or "text").strip().lower()
else:
value = "" if raw_entry is None else str(raw_entry)
field_type = "text"
if field_type not in {"text", "image", "source", "color", "colour"}:
field_type = "text"
result[name] = {"value": value[:8000], "type": field_type}
return result
@staticmethod
def _prepared_next_default_name(session: Any, *, user_id: str, device_uuid: str, game_id: str) -> str:
query = select(VmixPreparedTitle.name).where(VmixPreparedTitle.wfl_user_id == user_id)
if device_uuid:
query = query.where(VmixPreparedTitle.device_uuid == device_uuid)
if game_id:
query = query.where(VmixPreparedTitle.game_external_id == game_id)
used: set[int] = set()
for raw_name in session.scalars(query):
match = re.fullmatch(r"\s*Заготовка\s+(\d+)\s*", str(raw_name or ""), flags=re.IGNORECASE)
if match:
used.add(int(match.group(1)))
number = 1
while number in used:
number += 1
return f"Заготовка {number}"
@staticmethod
def _prepared_field_update_commands(
*,
input_ref: str,
available_fields: list[dict[str, Any]],
raw_values: Any,
existing_fields: dict[str, dict[str, str]] | None = None,
) -> tuple[dict[str, dict[str, str]], list[dict[str, Any]]]:
values = raw_values if isinstance(raw_values, dict) else {}
saved_fields = dict(existing_fields or {})
allowed: dict[str, dict[str, Any]] = {
str(field.get("name") or ""): field
for field in available_fields
if isinstance(field, dict) and str(field.get("name") or "")
}
# An old Agent inventory may temporarily miss fields of the freshly
# cloned Input. Existing saved fields are still safe edit targets.
for name, entry in saved_fields.items():
allowed.setdefault(name, {"name": name, "type": str(entry.get("type") or "text")})
commands: list[dict[str, Any]] = []
for raw_name, raw_entry in list(values.items())[:240]:
name = str(raw_name or "").strip()[:300]
field = allowed.get(name)
if not name or field is None:
continue
if isinstance(raw_entry, dict):
value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
requested_type = str(raw_entry.get("type") or "").strip().lower()
else:
value = "" if raw_entry is None else str(raw_entry)
requested_type = ""
existing_type = str(saved_fields.get(name, {}).get("type") or "").strip().lower()
field_type = str(field.get("type") or requested_type or existing_type or "text").strip().lower()
if field_type not in {"text", "image", "source", "color", "colour"}:
field_type = requested_type if requested_type in {"text", "image", "source", "color", "colour"} else "text"
saved_fields[name] = {"value": value[:8000], "type": field_type}
# Empty text deliberately clears inherited text. Some vMix title
# engines reject an empty image/source/color, so leave those alone.
if not value and field_type in {"image", "source", "color", "colour"}:
continue
commands.append({
"Function": _mapping_value_function(field_type),
"Input": input_ref,
"SelectedName": name,
"Value": value,
})
return saved_fields, commands
async def list_prepared_titles(self, user: HockeyUser, *, device_id: str = "", game_id: str = "") -> dict[str, Any]:
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, device_id)
@@ -2330,7 +2420,12 @@ class VmixAgentHub:
if found is not None:
row.clone_input_key = str(found.get("key") or row.clone_input_key or "")[:128]
row.clone_input_number = str(found.get("number") or row.clone_input_number or "")[:32]
row.clone_input_title = str(found.get("title") or row.clone_input_title or "")[:300]
found_title = str(found.get("title") or "")[:300]
# Right after SetInputName the Agent inventory can lag by one
# scan. Do not overwrite a freshly stored desired title with
# the old inherited source name during that short window.
if found_title and (str(row.clone_input_title or "") != str(row.name or "") or found_title == str(row.name or "")):
row.clone_input_title = found_title
items = [self._prepared_title_payload(row, inventory) for row in rows]
return {
"device_id": device.device_uuid,
@@ -2463,6 +2558,7 @@ class VmixAgentHub:
session_token = str(getattr(payload, "session_token", "") or "").strip()
with self.database.session() as session:
device = self._prepared_device_for_user(session, user, requested_device)
current_game_id = str(device.current_match_external_id or "").strip()
inventory = self._prepared_inventory_from_row(device)
source = self._prepared_find_input(
inventory,
@@ -2490,9 +2586,22 @@ class VmixAgentHub:
timeout=6.0,
)
target_device_id = str(create_result.get("device_id") or device.device_uuid)
match_id = str(create_result.get("match_id") or "")
match_id = str(create_result.get("match_id") or current_game_id or "").strip()
requested_name = str(getattr(payload, "name", "") or "").strip()[:200]
if requested_name:
title_name = requested_name
else:
with self.database.session() as session:
title_name = self._prepared_next_default_name(
session,
user_id=user.id,
device_uuid=target_device_id,
game_id=match_id,
)
clone: dict[str, Any] | None = None
current_inputs: list[dict[str, Any]] = []
for _ in range(12):
await asyncio.sleep(0.18)
with self.database.session() as session:
@@ -2505,50 +2614,43 @@ class VmixAgentHub:
break
clone_key = str(clone.get("key") or "") if clone else ""
clone_number = str(clone.get("number") or expected_number) if clone else expected_number
clone_title = str(clone.get("title") or source_title) if clone else source_title
clone_ref = clone_key or clone_number or clone_title
clone_ref = clone_key or clone_number or str(clone.get("title") or source_title if clone else source_title)
raw_values = getattr(payload, "field_values", {})
raw_values = raw_values if isinstance(raw_values, dict) else {}
source_fields = source.get("fields") if isinstance(source.get("fields"), list) else []
allowed = {str(field.get("name") or ""): field for field in source_fields if isinstance(field, dict) and str(field.get("name") or "")}
commands: list[dict[str, Any]] = []
saved_fields: dict[str, dict[str, str]] = {}
for name, field in list(allowed.items())[:240]:
if name not in raw_values:
continue
raw_entry = raw_values.get(name)
if isinstance(raw_entry, dict):
value = "" if raw_entry.get("value") is None else str(raw_entry.get("value"))
requested_type = str(raw_entry.get("type") or "")
else:
value = "" if raw_entry is None else str(raw_entry)
requested_type = ""
field_type = str(field.get("type") or requested_type or "text").lower()
if field_type not in {"text", "image", "source", "color", "colour"}:
field_type = requested_type.lower() if requested_type.lower() in {"text", "image", "source", "color", "colour"} else "text"
saved_fields[name] = {"value": value[:8000], "type": field_type}
# Empty text is meaningful (clear inherited content). Empty image/color is
# treated as "leave inherited value" because vMix rejects some empty sources.
if not value and field_type in {"image", "source", "color", "colour"}:
continue
commands.append({
"Function": _mapping_value_function(field_type),
"Input": clone_ref,
"SelectedName": name,
"Value": value,
})
set_result = None
if commands:
set_result = await self.run_vmix_sequence_for_user(
user,
commands,
device_id=target_device_id,
session_token=session_token,
timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
)
saved_fields, field_commands = self._prepared_field_update_commands(
input_ref=clone_ref,
available_fields=source_fields,
raw_values=raw_values,
)
# BUILD99: the prepared title name is also the actual vMix Input display
# name. vMix exposes SetInputName through the Shortcut/API surface.
commands: list[dict[str, Any]] = [
{"Function": "SetInputName", "Input": clone_ref, "Value": title_name},
*field_commands,
]
# vMix supports selecting categories via API, but does not expose a
# supported shortcut to create/label a custom category or assign an
# Input to it. Keep the safe fallback requested by the operator: the
# prepared title stays at the end of the project. CreateVirtualInput
# normally appends already; MoveInput makes that deterministic if the
# project reordered while the clone was being discovered.
current_numbers = [int(str(item.get("number") or "")) for item in current_inputs if str(item.get("number") or "").isdigit()]
end_position = max(current_numbers) if current_numbers else 0
if end_position > 0 and clone_number.isdigit() and int(clone_number) != end_position:
commands.append({"Function": "MoveInput", "Input": clone_ref, "Value": str(end_position)})
clone_number = str(end_position)
set_result = await self.run_vmix_sequence_for_user(
user,
commands,
device_id=target_device_id,
session_token=session_token,
timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
)
title_name = str(getattr(payload, "name", "") or "").strip()[:200] or f"{source_title or 'Title'} · заготовка"
source_kind = re.sub(r"[^A-Za-z0-9_-]+", "_", str(getattr(payload, "source_kind", "manual") or "manual").strip())[:32] or "manual"
source_ref_name = re.sub(r"[^A-Za-z0-9_.:-]+", "_", str(getattr(payload, "source_ref", "") or "").strip())[:128]
with self.database.session() as session:
@@ -2564,7 +2666,7 @@ class VmixAgentHub:
source_input_title=source_title,
clone_input_key=clone_key,
clone_input_number=clone_number,
clone_input_title=clone_title,
clone_input_title=title_name,
field_values_json=json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":")),
created_by=user.id,
created_at=_utcnow(),
@@ -2575,7 +2677,88 @@ class VmixAgentHub:
result = self._prepared_title_payload(row)
result["ok"] = True
result["create_result"] = create_result
result["set_result"] = set_result or {"ok": True, "applied": 0}
result["set_result"] = set_result
result["placement"] = {
"requested_category": "Заготовки",
"mode": "end",
"category_supported": False,
"reason": "vmix_shortcut_api_has_no_input_category_assignment",
}
return result
async def update_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
session_token = str(getattr(payload, "session_token", "") or "").strip()
with self.database.session() as session:
row = session.get(VmixPreparedTitle, int(prepared_id))
if row is None or row.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Заготовка не найдена")
requested_device = str(getattr(payload, "device_id", "") or row.device_uuid or "").strip()
device = self._prepared_device_for_user(session, user, requested_device)
inventory = self._prepared_inventory_from_row(device)
clone = self._prepared_find_input(
inventory,
key=str(row.clone_input_key or ""),
number=str(row.clone_input_number or ""),
title=str(row.clone_input_title or row.name or ""),
)
source = self._prepared_find_input(
inventory,
key=str(row.source_input_key or ""),
number=str(row.source_input_number or ""),
title=str(row.source_input_title or ""),
)
clone_ref = str(clone.get("key") or clone.get("number") or "") if clone is not None else str(row.clone_input_key or row.clone_input_number or row.clone_input_title or "")
if not clone_ref:
raise HTTPException(status_code=409, detail="Клонированный Input больше не найден в vMix")
old_fields = self._prepared_saved_fields(row)
available_fields = clone.get("fields") if clone is not None and isinstance(clone.get("fields"), list) else []
if not available_fields and source is not None and isinstance(source.get("fields"), list):
available_fields = source.get("fields")
old_name = str(row.name or "").strip()
requested_name = str(getattr(payload, "name", "") or "").strip()[:200]
title_name = requested_name or old_name
if not title_name:
title_name = self._prepared_next_default_name(
session,
user_id=user.id,
device_uuid=str(row.device_uuid or device.device_uuid or ""),
game_id=str(row.game_external_id or device.current_match_external_id or ""),
)
saved_fields, field_commands = self._prepared_field_update_commands(
input_ref=clone_ref,
available_fields=available_fields,
raw_values=getattr(payload, "field_values", {}),
existing_fields=old_fields,
)
target_device_id = device.device_uuid
commands: list[dict[str, Any]] = [
{"Function": "SetInputName", "Input": clone_ref, "Value": title_name},
*field_commands,
]
set_result = await self.run_vmix_sequence_for_user(
user,
commands,
device_id=target_device_id,
session_token=session_token,
timeout=max(4.0, min(12.0, 3.0 + len(commands) * 0.04)),
)
with self.database.session() as session:
row = session.get(VmixPreparedTitle, int(prepared_id))
if row is None or row.wfl_user_id != user.id:
raise HTTPException(status_code=404, detail="Заготовка не найдена")
row.name = title_name
row.clone_input_title = title_name[:300]
if clone is not None:
row.clone_input_key = str(clone.get("key") or row.clone_input_key or "")[:128]
row.clone_input_number = str(clone.get("number") or row.clone_input_number or "")[:32]
row.field_values_json = json.dumps(saved_fields, ensure_ascii=False, separators=(",", ":"))
row.updated_at = _utcnow()
result = self._prepared_title_payload(row)
result["ok"] = True
result["set_result"] = set_result
result["updated_existing_input"] = True
return result
async def preview_prepared_title(self, prepared_id: int, user: HockeyUser, payload: Any) -> dict[str, Any]:
@@ -3798,6 +3981,13 @@ class PreparedTitleCreatePayload(BaseModel):
field_values: dict[str, Any] = Field(default_factory=dict)
class PreparedTitleUpdatePayload(BaseModel):
name: str = Field(default="", max_length=200)
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
field_values: dict[str, Any] = Field(default_factory=dict)
class PreparedTitlePreviewPayload(BaseModel):
device_id: str = Field(default="", max_length=128)
session_token: str = Field(default="", max_length=128)
@@ -3871,6 +4061,14 @@ def create_hockey_agent_router(
) -> dict[str, Any]:
return await hub.create_prepared_title(user, payload)
@router.put("/api/hockey/prepared-titles/{prepared_id}")
async def prepared_title_update(
prepared_id: int,
payload: PreparedTitleUpdatePayload,
user: HockeyUser = Depends(auth_dependency),
) -> dict[str, Any]:
return await hub.update_prepared_title(prepared_id, user, payload)
@router.post("/api/hockey/prepared-titles/{prepared_id}/preview")
async def prepared_title_preview(
prepared_id: int,