1. поправлено работа с таймерами в vMix
2. поправлено немного Заготовки
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user