mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-09 15:35:39 +02:00
Add stream overlay logos and progress colours (#3208)
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
"""Server-stored streaming overlay logo. Mutation requires settings permission."""
|
||||
|
||||
import io
|
||||
import os
|
||||
import tempfile
|
||||
import warnings
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
from fastapi.responses import Response
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
from starlette.concurrency import run_in_threadpool
|
||||
|
||||
from backend.app.core.auth import RequireOverlayTokenIfAuthEnabled, RequirePermissionIfAuthEnabled
|
||||
from backend.app.core.config import settings
|
||||
from backend.app.core.permissions import Permission
|
||||
from backend.app.models.user import User
|
||||
|
||||
router = APIRouter(tags=["overlay-branding"])
|
||||
MAX_BYTES = 2 * 1024 * 1024
|
||||
MAX_PIXELS = 4_000_000
|
||||
|
||||
|
||||
def _read_logo() -> Response:
|
||||
try:
|
||||
content = (settings.base_dir / "overlay-branding" / "logo.png").read_bytes()
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(404, "No overlay logo saved") from None
|
||||
return Response(content, media_type="image/png", headers={"Cache-Control": "no-store"})
|
||||
|
||||
|
||||
def _save_logo(content: bytes) -> None:
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
||||
with Image.open(io.BytesIO(content)) as image:
|
||||
if image.format not in ("PNG", "WEBP") or image.width * image.height > MAX_PIXELS:
|
||||
raise HTTPException(400, "Use a PNG or WebP image with at most 4 million pixels")
|
||||
if getattr(image, "is_animated", False):
|
||||
raise HTTPException(400, "Animated logos are not supported")
|
||||
image.load()
|
||||
image = image.convert("RGBA")
|
||||
image.thumbnail((512, 512))
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG")
|
||||
except (UnidentifiedImageError, OSError, ValueError, Image.DecompressionBombError, Image.DecompressionBombWarning):
|
||||
raise HTTPException(400, "Invalid PNG or WebP image") from None
|
||||
directory = settings.base_dir / "overlay-branding"
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
# Replace atomically so concurrent readers never see a partially written PNG.
|
||||
with tempfile.NamedTemporaryFile(dir=directory, delete=False) as temporary:
|
||||
temporary_path = temporary.name
|
||||
try:
|
||||
temporary.write(output.getvalue())
|
||||
temporary.close()
|
||||
os.replace(temporary_path, directory / "logo.png")
|
||||
finally:
|
||||
if os.path.exists(temporary_path):
|
||||
os.unlink(temporary_path)
|
||||
|
||||
|
||||
@router.get("/settings/overlay-logo")
|
||||
def get_logo(_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ)):
|
||||
return _read_logo()
|
||||
|
||||
|
||||
@router.get("/overlay-branding/logo")
|
||||
def get_stream_logo(_: None = RequireOverlayTokenIfAuthEnabled):
|
||||
return _read_logo()
|
||||
|
||||
|
||||
@router.post("/settings/overlay-logo")
|
||||
async def upload_logo(
|
||||
file: UploadFile = File(...),
|
||||
_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
|
||||
):
|
||||
content = await file.read(MAX_BYTES + 1)
|
||||
if len(content) > MAX_BYTES:
|
||||
raise HTTPException(413, "Logo must be 2 MiB or smaller")
|
||||
await run_in_threadpool(_save_logo, content)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.delete("/settings/overlay-logo")
|
||||
def delete_logo(_: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE)):
|
||||
(settings.base_dir / "overlay-branding" / "logo.png").unlink(missing_ok=True)
|
||||
return {"status": "ok"}
|
||||
@@ -827,6 +827,7 @@ async def create_backup_zip(output_path: Path | None = None) -> tuple[Path, str]
|
||||
("plate_calibration", app_settings.plate_calibration_dir),
|
||||
("icons", base_dir / "icons"),
|
||||
("projects", base_dir / "projects"),
|
||||
("overlay-branding", base_dir / "overlay-branding"),
|
||||
]
|
||||
|
||||
for name, src_dir in dirs_to_backup:
|
||||
@@ -1515,6 +1516,7 @@ async def restore_backup(
|
||||
("plate_calibration", app_settings.plate_calibration_dir),
|
||||
("icons", base_dir / "icons"),
|
||||
("projects", base_dir / "projects"),
|
||||
("overlay-branding", base_dir / "overlay-branding"),
|
||||
]
|
||||
|
||||
skipped_dirs = []
|
||||
|
||||
@@ -56,6 +56,7 @@ from backend.app.api.routes import (
|
||||
notifications,
|
||||
obico,
|
||||
orca_cloud,
|
||||
overlay_branding,
|
||||
pending_uploads,
|
||||
pipeline_runs,
|
||||
print_log,
|
||||
@@ -10181,6 +10182,8 @@ PUBLIC_API_ROUTES = {
|
||||
# rejects an absent, expired, revoked, or wrong-scoped token. In particular a
|
||||
# plain ``camera_stream`` token does NOT open this door.
|
||||
"/api/v1/camwall/printers",
|
||||
# Overlay branding: the route enforces overlay-scoped token authentication.
|
||||
"/api/v1/overlay-branding/logo",
|
||||
}
|
||||
|
||||
# Route prefixes that are public (for routes with dynamic segments)
|
||||
@@ -10634,6 +10637,7 @@ app.include_router(finance.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(inventory.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(labels.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(settings_routes.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(overlay_branding.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(cloud.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(orca_cloud.router, prefix=app_settings.api_prefix)
|
||||
app.include_router(local_presets.router, prefix=app_settings.api_prefix)
|
||||
|
||||
@@ -19,7 +19,7 @@ import json
|
||||
import sqlite3
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -87,3 +87,45 @@ async def test_an_unimportable_backup_is_refused_with_both_versions(async_client
|
||||
assert "99.9.9" in detail
|
||||
assert APP_VERSION in detail
|
||||
assert "Nothing has been changed" in detail
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_overlay_logo_survives_backup_and_restore(async_client, monkeypatch, tmp_path):
|
||||
from backend.app.api.routes.settings import create_backup_zip
|
||||
|
||||
# Keep the restored database separate from the integration-test engine.
|
||||
db_path = tmp_path / "bambuddy.db"
|
||||
with sqlite3.connect(db_path) as db:
|
||||
db.execute("CREATE TABLE backup_marker (id INTEGER PRIMARY KEY)")
|
||||
monkeypatch.setenv("DATA_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(app_settings, "base_dir", tmp_path)
|
||||
monkeypatch.setattr(app_settings, "database_url", f"sqlite+aiosqlite:///{db_path}")
|
||||
logo_dir = tmp_path / "overlay-branding"
|
||||
logo_dir.mkdir()
|
||||
logo = logo_dir / "logo.png"
|
||||
original = b"saved overlay logo"
|
||||
logo.write_bytes(original)
|
||||
|
||||
zip_path, _ = await create_backup_zip(output_path=tmp_path)
|
||||
with zipfile.ZipFile(zip_path) as archive:
|
||||
assert archive.read("overlay-branding/logo.png") == original
|
||||
logo.write_bytes(b"replacement logo")
|
||||
(logo_dir / "stale.png").write_bytes(b"stale")
|
||||
|
||||
with (
|
||||
patch("backend.app.core.database.close_all_connections", new_callable=AsyncMock),
|
||||
patch("backend.app.core.database.reinitialize_database", new_callable=AsyncMock),
|
||||
patch("backend.app.core.database.init_db", new_callable=AsyncMock),
|
||||
patch("backend.app.services.print_scheduler.scheduler.stop"),
|
||||
patch("backend.app.services.smart_plug_manager.smart_plug_manager.stop_scheduler"),
|
||||
patch("backend.app.services.notification_service.notification_service.stop_digest_scheduler"),
|
||||
):
|
||||
response = await async_client.post(
|
||||
"/api/v1/settings/restore",
|
||||
files={"file": ("backup.zip", zip_path.read_bytes(), "application/zip")},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert logo.read_bytes() == original
|
||||
assert not (logo_dir / "stale.png").exists()
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Logo persistence, image validation, and overlay authentication."""
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from backend.tests.overlay_helpers import mint_token, setup_admin
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
def logo_bytes(fmt="PNG"):
|
||||
output = io.BytesIO()
|
||||
Image.new("RGBA", (80, 40), (255, 0, 0, 128)).save(output, format=fmt)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
async def test_logo_upload_read_remove(async_client, tmp_path, monkeypatch):
|
||||
from backend.app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "base_dir", tmp_path)
|
||||
response = await async_client.post(
|
||||
"/api/v1/settings/overlay-logo", files={"file": ("logo.webp", logo_bytes("WEBP"), "image/webp")}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
image = await async_client.get("/api/v1/overlay-branding/logo")
|
||||
assert image.status_code == 200
|
||||
assert image.headers["content-type"] == "image/png"
|
||||
with Image.open(io.BytesIO(image.content)) as decoded:
|
||||
assert decoded.size == (80, 40)
|
||||
assert decoded.mode == "RGBA"
|
||||
assert (await async_client.delete("/api/v1/settings/overlay-logo")).status_code == 200
|
||||
assert (await async_client.get("/api/v1/overlay-branding/logo")).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content", "status"),
|
||||
[(b"<svg></svg>", 400), (b"invalid", 400), (b"x" * (2 * 1024 * 1024 + 1), 413)],
|
||||
ids=["svg", "invalid", "oversized"],
|
||||
)
|
||||
async def test_rejects_invalid_uploads(async_client, tmp_path, monkeypatch, content, status):
|
||||
from backend.app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "base_dir", tmp_path)
|
||||
response = await async_client.post(
|
||||
"/api/v1/settings/overlay-logo", files={"file": ("logo.png", content, "image/png")}
|
||||
)
|
||||
assert response.status_code == status
|
||||
assert not (tmp_path / "overlay-branding" / "logo.png").exists()
|
||||
|
||||
|
||||
async def test_logo_auth_and_token_scope(async_client, tmp_path, monkeypatch):
|
||||
from backend.app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "base_dir", tmp_path)
|
||||
jwt = await setup_admin(async_client, suffix="_branding")
|
||||
headers = {"Authorization": f"Bearer {jwt}"}
|
||||
response = await async_client.post(
|
||||
"/api/v1/settings/overlay-logo",
|
||||
headers=headers,
|
||||
files={"file": ("logo.png", logo_bytes(), "image/png")},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert (await async_client.get("/api/v1/settings/overlay-logo", headers=headers)).status_code == 200
|
||||
assert (await async_client.get("/api/v1/settings/overlay-logo")).status_code == 401
|
||||
assert (await async_client.get("/api/v1/overlay-branding/logo")).status_code == 401
|
||||
for scope in ("camera_stream", "camwall", "overlay"):
|
||||
token = await mint_token(async_client, jwt, scope=scope, name=scope)
|
||||
response = await async_client.get("/api/v1/overlay-branding/logo", params={"token": token})
|
||||
assert response.status_code == (200 if scope == "overlay" else 401)
|
||||
assert (await async_client.delete("/api/v1/settings/overlay-logo", params={"token": token})).status_code == 401
|
||||
assert (await async_client.get("/api/v1/overlay-branding/logo?token=invalid")).status_code == 401
|
||||
|
||||
|
||||
async def test_invalid_replacement_preserves_logo(async_client, tmp_path, monkeypatch):
|
||||
from backend.app.core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "base_dir", tmp_path)
|
||||
await async_client.post("/api/v1/settings/overlay-logo", files={"file": ("logo.png", logo_bytes(), "image/png")})
|
||||
before = (await async_client.get("/api/v1/overlay-branding/logo")).content
|
||||
output = io.BytesIO()
|
||||
Image.new("RGB", (2100, 2100)).save(output, "PNG")
|
||||
response = await async_client.post(
|
||||
"/api/v1/settings/overlay-logo", files={"file": ("huge.png", output.getvalue(), "image/png")}
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert (await async_client.get("/api/v1/overlay-branding/logo")).content == before
|
||||
@@ -14,36 +14,11 @@ from __future__ import annotations
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from backend.tests.overlay_helpers import mint_token, setup_admin
|
||||
|
||||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||||
|
||||
|
||||
async def _setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": f"overlayadmin{suffix}",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": f"overlayadmin{suffix}", "password": "AdminPass1!"},
|
||||
)
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
async def _mint(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "obs") -> str:
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
json={"name": name, "expires_in_days": 30, "scope": scope},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["scope"] == scope
|
||||
return response.json()["token"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def printer_row(db_session):
|
||||
"""Insert the printer straight into the DB.
|
||||
@@ -67,12 +42,12 @@ async def printer_row(db_session):
|
||||
|
||||
class TestOverlayFeedAuth:
|
||||
async def test_no_token_is_rejected(self, async_client: AsyncClient, printer_row):
|
||||
await _setup_admin(async_client, suffix="_notoken")
|
||||
await setup_admin(async_client, suffix="_notoken")
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_garbage_token_is_rejected(self, async_client: AsyncClient, printer_row):
|
||||
await _setup_admin(async_client, suffix="_garbage")
|
||||
await setup_admin(async_client, suffix="_garbage")
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token=bblt_aaaaaaaa_nope")
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -81,8 +56,8 @@ class TestOverlayFeedAuth:
|
||||
acquire the live print status (and filename) just because a new feature
|
||||
shipped.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_streamscope")
|
||||
stream_token = await _mint(async_client, jwt, scope="camera_stream")
|
||||
jwt = await setup_admin(async_client, suffix="_streamscope")
|
||||
stream_token = await mint_token(async_client, jwt, scope="camera_stream")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={stream_token}")
|
||||
assert response.status_code == 401
|
||||
@@ -95,22 +70,22 @@ class TestOverlayFeedAuth:
|
||||
rejected here — otherwise every wall token silently gains filename
|
||||
visibility.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_camwallscope")
|
||||
camwall_token = await _mint(async_client, jwt, scope="camwall")
|
||||
jwt = await setup_admin(async_client, suffix="_camwallscope")
|
||||
camwall_token = await mint_token(async_client, jwt, scope="camwall")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={camwall_token}")
|
||||
assert response.status_code == 401
|
||||
|
||||
async def test_overlay_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
|
||||
jwt = await _setup_admin(async_client, suffix="_rightscope")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
jwt = await setup_admin(async_client, suffix="_rightscope")
|
||||
overlay_token = await mint_token(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["name"] == "Stream P1S"
|
||||
|
||||
async def test_revoked_overlay_token_is_rejected(self, async_client: AsyncClient, printer_row):
|
||||
jwt = await _setup_admin(async_client, suffix="_revoked")
|
||||
jwt = await setup_admin(async_client, suffix="_revoked")
|
||||
created = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
@@ -132,8 +107,8 @@ class TestOverlayFeedPayload:
|
||||
that is what distinguishes the scope. Assert the exact key set so the
|
||||
payload can't silently grow to leak more than the overlay draws.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_payload")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
jwt = await setup_admin(async_client, suffix="_payload")
|
||||
overlay_token = await mint_token(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 200
|
||||
@@ -165,8 +140,8 @@ class TestOverlayFeedPayload:
|
||||
"""No MQTT client runs in tests, so the printer has no state — the
|
||||
overlay must render its offline state rather than erroring.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_offline")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
jwt = await setup_admin(async_client, suffix="_offline")
|
||||
overlay_token = await mint_token(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
entry = response.json()
|
||||
@@ -210,8 +185,8 @@ class TestOverlayFeedPayload:
|
||||
|
||||
monkeypatch.setattr(pm.printer_manager, "get_status", lambda _pid: _FakeState())
|
||||
|
||||
jwt = await _setup_admin(async_client, suffix="_temps")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
jwt = await setup_admin(async_client, suffix="_temps")
|
||||
overlay_token = await mint_token(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
assert response.json()["model"] == "P1S"
|
||||
@@ -229,8 +204,8 @@ class TestOverlayFeedPayload:
|
||||
async def test_unknown_model_is_null(self, async_client: AsyncClient, printer_row, db_session):
|
||||
printer_row.model = None
|
||||
await db_session.commit()
|
||||
jwt = await _setup_admin(async_client, suffix="_nomodel")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
jwt = await setup_admin(async_client, suffix="_nomodel")
|
||||
overlay_token = await mint_token(async_client, jwt, scope="overlay")
|
||||
response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["model"] is None
|
||||
@@ -239,8 +214,8 @@ class TestOverlayFeedPayload:
|
||||
"""A valid token for a printer id that doesn't exist is a 404 — the token
|
||||
passed the gate, the resource simply isn't there.
|
||||
"""
|
||||
jwt = await _setup_admin(async_client, suffix="_404")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
jwt = await setup_admin(async_client, suffix="_404")
|
||||
overlay_token = await mint_token(async_client, jwt, scope="overlay")
|
||||
|
||||
response = await async_client.get(f"/api/v1/printers/99999/overlay-status?token={overlay_token}")
|
||||
assert response.status_code == 404
|
||||
@@ -254,17 +229,17 @@ class TestOverlayTokenReachesTheVideo:
|
||||
async def test_overlay_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
|
||||
from backend.app.core.auth import verify_camera_stream_token
|
||||
|
||||
jwt = await _setup_admin(async_client, suffix="_video")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
jwt = await setup_admin(async_client, suffix="_video")
|
||||
overlay_token = await mint_token(async_client, jwt, scope="overlay")
|
||||
|
||||
assert await verify_camera_stream_token(overlay_token) is True
|
||||
|
||||
async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
|
||||
from backend.app.core.auth import verify_overlay_token
|
||||
|
||||
jwt = await _setup_admin(async_client, suffix="_gate")
|
||||
stream_token = await _mint(async_client, jwt, scope="camera_stream")
|
||||
camwall_token = await _mint(async_client, jwt, scope="camwall", name="wall")
|
||||
jwt = await setup_admin(async_client, suffix="_gate")
|
||||
stream_token = await mint_token(async_client, jwt, scope="camera_stream")
|
||||
camwall_token = await mint_token(async_client, jwt, scope="camwall", name="wall")
|
||||
|
||||
assert await verify_overlay_token(stream_token) is False
|
||||
assert await verify_overlay_token(camwall_token) is False
|
||||
@@ -273,7 +248,7 @@ class TestOverlayTokenReachesTheVideo:
|
||||
"""Symmetric guard: the new scope must not widen the Cam Wall either."""
|
||||
from backend.app.core.auth import verify_camwall_token
|
||||
|
||||
jwt = await _setup_admin(async_client, suffix="_gate_camwall")
|
||||
overlay_token = await _mint(async_client, jwt, scope="overlay")
|
||||
jwt = await setup_admin(async_client, suffix="_gate_camwall")
|
||||
overlay_token = await mint_token(async_client, jwt, scope="overlay")
|
||||
|
||||
assert await verify_camwall_token(overlay_token) is False
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Shared authentication helpers for overlay integration tests."""
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
async def setup_admin(async_client: AsyncClient, *, suffix: str) -> str:
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": f"overlayadmin{suffix}",
|
||||
"admin_password": "AdminPass1!",
|
||||
},
|
||||
)
|
||||
login = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": f"overlayadmin{suffix}", "password": "AdminPass1!"},
|
||||
)
|
||||
return login.json()["access_token"]
|
||||
|
||||
|
||||
async def mint_token(async_client: AsyncClient, jwt: str, *, scope: str, name: str = "obs") -> str:
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/tokens",
|
||||
headers={"Authorization": f"Bearer {jwt}"},
|
||||
json={"name": name, "expires_in_days": 30, "scope": scope},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["scope"] == scope
|
||||
return response.json()["token"]
|
||||
@@ -6,8 +6,8 @@
|
||||
* camera stream until it is asked to.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor, fireEvent, act } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { render } from '../utils';
|
||||
@@ -27,7 +27,7 @@ function shownUrl(): string {
|
||||
|
||||
describe('StreamOverlayBuilder', () => {
|
||||
beforeEach(() => {
|
||||
server.use(http.get('/api/v1/printers', () => HttpResponse.json(printers)));
|
||||
server.use(http.get('/api/v1/printers', () => HttpResponse.json(printers)), http.get('/api/v1/settings/overlay-logo', () => new HttpResponse(null, { status: 404 })));
|
||||
});
|
||||
|
||||
it('starts on the first printer with the overlay defaults', async () => {
|
||||
@@ -60,12 +60,12 @@ describe('StreamOverlayBuilder', () => {
|
||||
await user.click(screen.getByLabelText('Printer name'));
|
||||
expect(new URL(shownUrl()).searchParams.get('show')).toBe('printer,model,filename,status,progress,layers,eta');
|
||||
await user.click(screen.getByRole('button', { name: 'Show preview' }));
|
||||
expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl());
|
||||
await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl()));
|
||||
|
||||
await user.click(model);
|
||||
await user.click(screen.getByLabelText('Printer name'));
|
||||
expect(shownUrl()).toBe(originalUrl);
|
||||
expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', originalUrl);
|
||||
await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', originalUrl));
|
||||
});
|
||||
|
||||
it('opts into artwork in the URL and preview and restores the original URL', async () => {
|
||||
@@ -78,10 +78,30 @@ describe('StreamOverlayBuilder', () => {
|
||||
await user.selectOptions(artwork, 'Version 2');
|
||||
expect(new URL(shownUrl()).searchParams.get('artwork')).toBe('2');
|
||||
await user.click(screen.getByRole('button', { name: 'Show preview' }));
|
||||
expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl());
|
||||
await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl()));
|
||||
await user.selectOptions(artwork, 'Classic');
|
||||
expect(shownUrl()).toBe(original);
|
||||
expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', original);
|
||||
await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', original));
|
||||
});
|
||||
|
||||
it('only offers background transparency for Version 2 and preserves its selection', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<StreamOverlayBuilder />);
|
||||
const artwork = await screen.findByLabelText('Artwork');
|
||||
expect(screen.queryByRole('slider', { name: /Background transparency/ })).not.toBeInTheDocument();
|
||||
await user.selectOptions(artwork, 'Version 2');
|
||||
const slider = screen.getByRole('slider', { name: /Background transparency/ });
|
||||
expect(slider).toHaveValue('0');
|
||||
expect(shownUrl()).not.toContain('backgroundTransparency');
|
||||
fireEvent.change(slider, { target: { value: '65' } });
|
||||
expect(new URL(shownUrl()).searchParams.get('backgroundTransparency')).toBe('65');
|
||||
await user.click(screen.getByRole('button', { name: 'Show preview' }));
|
||||
await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl()));
|
||||
await user.selectOptions(artwork, 'Classic');
|
||||
expect(screen.queryByRole('slider', { name: /Background transparency/ })).not.toBeInTheDocument();
|
||||
expect(shownUrl()).not.toContain('backgroundTransparency');
|
||||
await user.selectOptions(artwork, 'Version 2');
|
||||
expect(screen.getByRole('slider', { name: /Background transparency/ })).toHaveValue('65');
|
||||
});
|
||||
|
||||
it('switches printer', async () => {
|
||||
@@ -190,3 +210,103 @@ describe('StreamOverlayBuilder', () => {
|
||||
await waitFor(() => expect(shownUrl()).toContain('/overlay/1'));
|
||||
});
|
||||
});
|
||||
|
||||
it('adds validated gradient colours to the URL and resets to the default', async () => {
|
||||
render(<StreamOverlayBuilder />);
|
||||
const from = await screen.findByLabelText('From colour (hex)');
|
||||
fireEvent.change(from, { target: { value: '#ff0000' } });
|
||||
fireEvent.change(screen.getByLabelText('To colour (hex)'), { target: { value: '#0000ff' } });
|
||||
expect(new URL(shownUrl()).searchParams.get('progressFrom')).toBe('#ff0000');
|
||||
expect(new URL(shownUrl()).searchParams.get('progressTo')).toBe('#0000ff');
|
||||
fireEvent.change(from, { target: { value: 'invalid' } });
|
||||
expect(new URL(shownUrl()).searchParams.get('progressFrom')).toBe('#ff0000');
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Reset colours' }));
|
||||
expect(shownUrl()).not.toContain('progressFrom');
|
||||
expect(shownUrl()).not.toContain('progressTo');
|
||||
});
|
||||
|
||||
it('uploads a logo, includes it in the URL, and removes it from the preview', async () => {
|
||||
const user = userEvent.setup();
|
||||
let saved = false;
|
||||
const create = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:logo-preview');
|
||||
const revoke = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
||||
server.use(
|
||||
http.get('/api/v1/printers', () => HttpResponse.json(printers)),
|
||||
http.get('/api/v1/settings/overlay-logo', () => saved ? new HttpResponse(new Blob(['png'], { type: 'image/png' })) : new HttpResponse(null, { status: 404 })),
|
||||
http.post('/api/v1/settings/overlay-logo', () => { saved = true; return HttpResponse.json({ status: 'ok' }); }),
|
||||
http.delete('/api/v1/settings/overlay-logo', () => { saved = false; return HttpResponse.json({ status: 'ok' }); }),
|
||||
);
|
||||
try {
|
||||
render(<StreamOverlayBuilder />);
|
||||
await user.upload(await screen.findByLabelText('Upload logo'), new File(['png'], 'logo.png', { type: 'image/png' }));
|
||||
expect(await screen.findByRole('img', { name: 'Custom logo' })).toHaveAttribute('src', 'blob:logo-preview');
|
||||
expect(new URL(shownUrl()).searchParams.get('logo')).toBe('1');
|
||||
await user.click(screen.getByRole('button', { name: 'Show preview' }));
|
||||
await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl()));
|
||||
const previousPreview = screen.getByTitle('Overlay preview');
|
||||
const previousUrl = shownUrl();
|
||||
await user.upload(screen.getByLabelText('Upload logo'), new File(['new png'], 'replacement.png', { type: 'image/png' }));
|
||||
await waitFor(() => expect(screen.getByTitle('Overlay preview')).not.toBe(previousPreview));
|
||||
expect(shownUrl()).toBe(previousUrl);
|
||||
expect(await screen.findByRole('img', { name: 'Custom logo' })).toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: 'Remove' }));
|
||||
await waitFor(() => expect(screen.queryByRole('img', { name: 'Custom logo' })).not.toBeInTheDocument());
|
||||
expect(new URL(shownUrl()).searchParams.has('logo')).toBe(false);
|
||||
await waitFor(() => expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl()));
|
||||
} finally {
|
||||
create.mockRestore();
|
||||
revoke.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('debounces continuous preview changes and cancels a pending reload when hidden', async () => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers', () => HttpResponse.json(printers)),
|
||||
http.get('/api/v1/settings/overlay-logo', () => new HttpResponse(null, { status: 404 })),
|
||||
);
|
||||
render(<StreamOverlayBuilder />);
|
||||
await screen.findByRole('option', { name: 'X1 Carbon' });
|
||||
fireEvent.change(screen.getByLabelText('Artwork'), { target: { value: '2' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show preview' }));
|
||||
const original = screen.getByTitle('Overlay preview');
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
fireEvent.change(screen.getByLabelText('From colour'), { target: { value: '#ff0000' } });
|
||||
act(() => vi.advanceTimersByTime(200));
|
||||
fireEvent.change(screen.getByRole('slider', { name: /Background transparency/ }), { target: { value: '65' } });
|
||||
act(() => vi.advanceTimersByTime(299));
|
||||
expect(screen.getByTitle('Overlay preview')).toBe(original);
|
||||
expect(original).not.toHaveAttribute('src', shownUrl());
|
||||
act(() => vi.advanceTimersByTime(1));
|
||||
expect(screen.getByTitle('Overlay preview')).not.toBe(original);
|
||||
expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('From colour'), { target: { value: '#0000ff' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide preview' }));
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(screen.queryByTitle('Overlay preview')).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Show preview' }));
|
||||
expect(screen.getByTitle('Overlay preview')).toHaveAttribute('src', shownUrl());
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Hide preview' }));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
[400, { detail: 'Logo must be a static PNG or WebP image' }, 'Logo must be a static PNG or WebP image'],
|
||||
[413, { detail: { message: 'Logo must be at most 2 MiB' } }, 'Logo must be at most 2 MiB'],
|
||||
[502, null, 'HTTP 502'],
|
||||
])('shows the server upload error for status %s', async (status, body, message) => {
|
||||
server.use(
|
||||
http.get('/api/v1/printers', () => HttpResponse.json(printers)),
|
||||
http.get('/api/v1/settings/overlay-logo', () => new HttpResponse(null, { status: 404 })),
|
||||
http.post('/api/v1/settings/overlay-logo', () => body ? HttpResponse.json(body, { status }) : new HttpResponse('Bad gateway', { status })),
|
||||
);
|
||||
const user = userEvent.setup();
|
||||
render(<StreamOverlayBuilder />);
|
||||
await user.upload(screen.getByLabelText('Upload logo'), new File(['invalid'], 'logo.png', { type: 'image/png' }));
|
||||
expect(await screen.findByText(message)).toBeInTheDocument();
|
||||
expect(new URL(shownUrl()).searchParams.has('logo')).toBe(false);
|
||||
expect(screen.getByLabelText('Upload logo')).toBeEnabled();
|
||||
});
|
||||
|
||||
@@ -106,6 +106,46 @@ describe('StreamOverlayPage', () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it.each(['1', '2'])('uses the requested gradient in artwork %s', async (artwork) => {
|
||||
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatusPrinting)));
|
||||
const { container } = renderOverlayPage(1, `?artwork=${artwork}&progressFrom=%23ff0000&progressTo=%230000ff`);
|
||||
await screen.findByAltText('Bambuddy');
|
||||
const bar = container.querySelector('[style*="width: 45%"]');
|
||||
expect(bar).toHaveStyle({ width: '45%', background: 'linear-gradient(to right, #ff0000, #0000ff)' });
|
||||
for (const text of [screen.getByText('Progress'), screen.getByText('45%')]) {
|
||||
expect(text).toHaveStyle({
|
||||
'background-image': 'linear-gradient(to right, #ff0000, #0000ff)',
|
||||
'background-clip': 'text',
|
||||
color: 'rgba(0, 0, 0, 0)',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['1', '2'])('ignores malformed branding colours in artwork %s', async (artwork) => {
|
||||
server.use(http.get('/api/v1/printers/:id/status', () => HttpResponse.json(mockStatusPrinting)));
|
||||
const { container } = renderOverlayPage(1, `?artwork=${artwork}&progressFrom=red&progressTo=%230000ff`);
|
||||
await screen.findByAltText('Bambuddy');
|
||||
expect(container.querySelector('[style*="linear-gradient"]')).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['0', '1'], ['50', '0.5'], ['100', '0'], ['-10', '1'], ['150', '0'], ['invalid', '1'], ['', '1'],
|
||||
])('validates Version 2 background transparency %s', async (value, alpha) => {
|
||||
const { container, unmount } = renderOverlayPage(1, `?artwork=2&camera=false&backgroundTransparency=${value}`);
|
||||
await screen.findByAltText('Bambuddy');
|
||||
expect(container.querySelector('.updated-overlay')).toHaveStyle({ '--overlay-background-alpha': alpha });
|
||||
if (alpha !== '1') expect(document.body.style.backgroundColor).toBe('transparent');
|
||||
unmount();
|
||||
expect(document.body.style.backgroundColor).not.toBe('transparent');
|
||||
});
|
||||
|
||||
it('ignores background transparency in Classic', async () => {
|
||||
const { container } = renderOverlayPage(1, '?backgroundTransparency=100');
|
||||
await screen.findByAltText('Bambuddy');
|
||||
expect(container.querySelector('[style*="--overlay-background-alpha"]')).toBeNull();
|
||||
expect(document.body.style.backgroundColor).not.toBe('transparent');
|
||||
});
|
||||
|
||||
it.each(['', '&artwork=2'])('reconnects the kiosk camera without changing its token or settings (%s)', async (artwork) => {
|
||||
server.use(http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
|
||||
...mockStatusIdle, camera_rotation: 90,
|
||||
|
||||
@@ -4617,6 +4617,33 @@ export interface AuthStatus {
|
||||
|
||||
// API functions
|
||||
export const api = {
|
||||
// Overlay branding
|
||||
getOverlayLogo: async (token: string | null, signal?: AbortSignal): Promise<Blob | null> => {
|
||||
const endpoint = token ? `/overlay-branding/logo?token=${encodeURIComponent(token)}` : '/settings/overlay-logo';
|
||||
const response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
signal, cache: 'no-store',
|
||||
headers: !token && authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
});
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.blob();
|
||||
},
|
||||
uploadOverlayLogo: async (file: File): Promise<void> => {
|
||||
const body = new FormData();
|
||||
body.append('file', file);
|
||||
const response = await fetch(`${API_BASE}/settings/overlay-logo`, {
|
||||
method: 'POST', body,
|
||||
headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({}));
|
||||
const detail = error?.detail;
|
||||
const message = typeof detail === 'string' ? detail : detail?.message;
|
||||
throw new Error(typeof message === 'string' && message ? message : `HTTP ${response.status}`);
|
||||
}
|
||||
},
|
||||
deleteOverlayLogo: () => request<{ status: string }>('/settings/overlay-logo', { method: 'DELETE' }),
|
||||
|
||||
// Authentication
|
||||
getAuthStatus: () => request<AuthStatus>('/auth/status'),
|
||||
setupAuth: (data: SetupRequest) =>
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { useOverlayLogo } from '../hooks/useOverlayLogo';
|
||||
import { isOverlayColour, type OverlayBranding } from '../utils/overlayBranding';
|
||||
|
||||
export function OverlayBrandingControls({ value, onChange }: {
|
||||
value: OverlayBranding;
|
||||
onChange: (value: OverlayBranding) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { showToast } = useToast();
|
||||
const { authEnabled, hasPermission } = useAuth();
|
||||
const canEdit = !authEnabled || hasPermission('settings:update');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [draft, setDraft] = useState({ from: '#00ae42', to: '#00ae42' });
|
||||
const logo = useOverlayLogo(true, null, value.logoRevision);
|
||||
|
||||
const saveLogo = async (file: File | null) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
if (file) await api.uploadOverlayLogo(file);
|
||||
else await api.deleteOverlayLogo();
|
||||
onChange({ ...value, logo: file !== null, logoRevision: value.logoRevision + 1 });
|
||||
} catch (error) {
|
||||
showToast(error instanceof Error ? error.message : t('streamOverlay.branding.failed'), 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const setColour = (key: 'from' | 'to', colour: string) => {
|
||||
setDraft((current) => ({ ...current, [key]: colour }));
|
||||
if (isOverlayColour(colour)) {
|
||||
onChange({ ...value, from: value.from || '#00ae42', to: value.to || '#00ae42', [key]: colour });
|
||||
}
|
||||
};
|
||||
|
||||
return <fieldset disabled={busy} className="my-4 space-y-3 rounded-lg border border-bambu-dark-tertiary p-4">
|
||||
<legend className="px-2 text-sm font-medium text-white">{t('streamOverlay.branding.title')}</legend>
|
||||
<p className="text-xs text-bambu-gray">{t('streamOverlay.branding.hint')}</p>
|
||||
<label className="block text-sm text-bambu-gray">
|
||||
{t('streamOverlay.branding.upload')}
|
||||
<input type="file" accept="image/png,image/webp" disabled={!canEdit}
|
||||
className="mt-1 block w-full text-sm"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (file) void saveLogo(file);
|
||||
}} />
|
||||
</label>
|
||||
{logo && <div className="flex flex-wrap items-center gap-3">
|
||||
<img src={logo} alt={t('streamOverlay.branding.logo')} className="h-16 max-w-40 object-contain" />
|
||||
<label className="flex items-center gap-2 text-sm text-bambu-gray">
|
||||
<input type="checkbox" checked={value.logo} onChange={(event) => onChange({ ...value, logo: event.target.checked })} />
|
||||
{t('streamOverlay.branding.logo')}
|
||||
</label>
|
||||
<button type="button" disabled={!canEdit} onClick={() => void saveLogo(null)} className="text-sm text-red-400 disabled:opacity-50">{t('common.remove')}</button>
|
||||
</div>}
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{(['from', 'to'] as const).map((key) => <div key={key}>
|
||||
<label htmlFor={`overlay-colour-${key}`} className="block text-sm text-bambu-gray mb-1">{t(`streamOverlay.branding.${key}`)}</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input id={`overlay-colour-${key}`} type="color" value={value[key] || '#00ae42'} onChange={(event) => setColour(key, event.target.value)} className="h-9 w-12 bg-transparent" />
|
||||
<input aria-label={`${t(`streamOverlay.branding.${key}`)} (hex)`} value={draft[key]} onChange={(event) => setColour(key, event.target.value)}
|
||||
aria-invalid={!isOverlayColour(draft[key])} maxLength={7} spellCheck={false}
|
||||
className="w-28 rounded border border-bambu-dark-tertiary bg-bambu-dark px-2 py-1 text-white" />
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
<button type="button" className="text-sm text-bambu-green" onClick={() => {
|
||||
setDraft({ from: '#00ae42', to: '#00ae42' });
|
||||
onChange({ ...value, from: '', to: '' });
|
||||
}}>{t('streamOverlay.branding.reset')}</button>
|
||||
</fieldset>;
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Copy, ExternalLink, Eye, EyeOff } from 'lucide-react';
|
||||
import { api, type Printer } from '../api/client';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import { OverlayBrandingControls } from './OverlayBrandingControls';
|
||||
import { DEFAULT_BRANDING } from '../utils/overlayBranding';
|
||||
import { NumberInput } from './NumberInput';
|
||||
|
||||
type OverlaySize = 'small' | 'medium' | 'large';
|
||||
@@ -50,8 +52,10 @@ export function StreamOverlayBuilder() {
|
||||
// '1' is the original overlay; the renderer is picked by version, not by a
|
||||
// name like "updated" that stops being true once there's a newer one.
|
||||
const [artwork, setArtwork] = useState<'1' | '2'>('1');
|
||||
const [backgroundTransparency, setBackgroundTransparency] = useState(0);
|
||||
const [showCamera, setShowCamera] = useState(true);
|
||||
const [token, setToken] = useState('');
|
||||
const [branding, setBranding] = useState(DEFAULT_BRANDING);
|
||||
const [preview, setPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,10 +88,18 @@ export function StreamOverlayBuilder() {
|
||||
if (size !== 'medium') params.set('size', size);
|
||||
if (fps !== DEFAULT_FPS) params.set('fps', String(fps));
|
||||
if (artwork !== '1') params.set('artwork', artwork);
|
||||
if (artwork === '2' && backgroundTransparency > 0) {
|
||||
params.set('backgroundTransparency', String(backgroundTransparency));
|
||||
}
|
||||
if (!showCamera) params.set('camera', 'false');
|
||||
if (branding.logo) params.set('logo', '1');
|
||||
if (branding.from && branding.to) {
|
||||
params.set('progressFrom', branding.from);
|
||||
params.set('progressTo', branding.to);
|
||||
}
|
||||
if (token.trim()) params.set('token', token.trim());
|
||||
return `${window.location.origin}/overlay/${id}?${params.toString()}`;
|
||||
}, [printerId, fields, size, fps, showCamera, token, artwork]);
|
||||
}, [printerId, fields, size, fps, showCamera, token, artwork, branding, backgroundTransparency]);
|
||||
|
||||
const toggleField = (key: string) => {
|
||||
setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
|
||||
@@ -181,6 +193,27 @@ export function StreamOverlayBuilder() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{artwork === '2' && (
|
||||
<div>
|
||||
<label htmlFor="overlay-builder-background-transparency" className="flex justify-between gap-2 text-sm font-medium text-white mb-1">
|
||||
<span>{t('streamOverlay.builder.backgroundTransparency')}</span>
|
||||
<span aria-hidden="true">{backgroundTransparency}%</span>
|
||||
</label>
|
||||
<input
|
||||
id="overlay-builder-background-transparency"
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={1}
|
||||
value={backgroundTransparency}
|
||||
aria-valuetext={`${backgroundTransparency}%`}
|
||||
onChange={(event) => setBackgroundTransparency(Number(event.target.value))}
|
||||
className="w-full accent-bambu-green"
|
||||
/>
|
||||
<p className="text-xs text-bambu-gray mt-1">{t('streamOverlay.builder.backgroundTransparencyHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label htmlFor="overlay-builder-fps" className="block text-sm font-medium text-white mb-1">
|
||||
{t('streamOverlay.builder.fps', 'Frame rate')}
|
||||
@@ -257,6 +290,8 @@ export function StreamOverlayBuilder() {
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<OverlayBrandingControls value={branding} onChange={setBranding} />
|
||||
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-white mb-1">
|
||||
{t('streamOverlay.builder.urlTitle', 'Overlay URL')}
|
||||
@@ -309,14 +344,31 @@ export function StreamOverlayBuilder() {
|
||||
: t('streamOverlay.builder.showPreview', 'Show preview')}
|
||||
</button>
|
||||
{preview && (
|
||||
<iframe
|
||||
key={url}
|
||||
src={url}
|
||||
<OverlayPreview
|
||||
url={url}
|
||||
logoRevision={branding.logoRevision}
|
||||
title={t('streamOverlay.builder.previewTitle', 'Overlay preview')}
|
||||
className="mt-3 w-full aspect-video rounded-md border border-bambu-dark-tertiary bg-black"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverlayPreview({ url, logoRevision, title }: { url: string; logoRevision: number; title: string }) {
|
||||
const [source, setSource] = useState({ url, logoRevision });
|
||||
|
||||
useEffect(() => {
|
||||
// Colour and transparency controls emit continuously while dragging.
|
||||
// Wait for them to settle before opening another camera stream.
|
||||
const timeout = window.setTimeout(() => setSource({ url, logoRevision }), 300);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [url, logoRevision]);
|
||||
|
||||
return <iframe
|
||||
key={`${source.url}:${source.logoRevision}`}
|
||||
src={source.url}
|
||||
title={title}
|
||||
className="mt-3 w-full aspect-video rounded-md border border-bambu-dark-tertiary bg-black"
|
||||
/>;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
justify-content: space-between;
|
||||
min-height: 100vh;
|
||||
color: #fff;
|
||||
background: #10171b;
|
||||
background: rgb(16 23 27 / var(--overlay-background-alpha, 1));
|
||||
font-size: calc(clamp(14px, 2.2vmin, 28px) * var(--overlay-scale));
|
||||
line-height: 1.3;
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -35,7 +35,7 @@
|
||||
min-width: 0;
|
||||
padding: 0.65em 0.9em;
|
||||
border-radius: 0.7em;
|
||||
background: rgb(12 19 23 / 90%);
|
||||
background: rgb(12 19 23 / var(--overlay-identity-alpha, 0.9));
|
||||
}
|
||||
.updated-overlay__identity svg {
|
||||
flex-shrink: 0;
|
||||
@@ -58,7 +58,7 @@
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
.updated-overlay__logo img {
|
||||
.updated-overlay__logo > a img {
|
||||
width: clamp(100px, 19vmin, 230px);
|
||||
height: auto;
|
||||
}
|
||||
@@ -79,7 +79,7 @@
|
||||
}
|
||||
.updated-overlay__panel {
|
||||
padding: var(--overlay-space);
|
||||
background: rgb(12 19 23 / 88%);
|
||||
background: rgb(12 19 23 / var(--overlay-panel-alpha, 0.88));
|
||||
}
|
||||
.updated-overlay__summary {
|
||||
display: flex;
|
||||
@@ -200,7 +200,7 @@
|
||||
.updated-overlay__header {
|
||||
grid-row: 1;
|
||||
align-items: center;
|
||||
background: #10171b;
|
||||
background: rgb(16 23 27 / var(--overlay-background-alpha, 1));
|
||||
}
|
||||
.updated-overlay__identity {
|
||||
background: none;
|
||||
@@ -214,7 +214,7 @@
|
||||
}
|
||||
.updated-overlay__panel {
|
||||
grid-row: 3;
|
||||
background: #10171b;
|
||||
background: rgb(16 23 27 / var(--overlay-background-alpha, 1));
|
||||
}
|
||||
.updated-overlay__summary {
|
||||
flex-direction: column-reverse;
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from 'react';
|
||||
import { useLayoutEffect, useRef, useState, type CSSProperties, type ReactNode, type RefObject } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Clock, Layers, Printer, Timer } from 'lucide-react';
|
||||
import './UpdatedStreamOverlay.css';
|
||||
import { overlayProgressTextStyle } from '../utils/overlayBranding';
|
||||
|
||||
interface UpdatedStreamOverlayProps {
|
||||
backgroundTransparency?: number;
|
||||
customLogo?: ReactNode;
|
||||
progressBackground?: string;
|
||||
size: 'small' | 'medium' | 'large';
|
||||
camera: { url: string; rotation: number; onError: () => void } | null;
|
||||
name: string | null;
|
||||
@@ -50,8 +54,27 @@ function useCameraBox(ref: RefObject<HTMLDivElement | null>, active: boolean) {
|
||||
|
||||
export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
const backgroundAlpha = 1 - (props.backgroundTransparency ?? 0) / 100;
|
||||
const backgroundStyle: CSSProperties & {
|
||||
'--overlay-background-alpha': number;
|
||||
'--overlay-identity-alpha': number;
|
||||
'--overlay-panel-alpha': number;
|
||||
} = {
|
||||
'--overlay-background-alpha': backgroundAlpha,
|
||||
'--overlay-identity-alpha': backgroundAlpha * 0.9,
|
||||
'--overlay-panel-alpha': backgroundAlpha * 0.88,
|
||||
};
|
||||
useLayoutEffect(() => {
|
||||
if (backgroundAlpha === 1) return;
|
||||
// The app's body background would otherwise fill every transparent pixel
|
||||
// in OBS. Restore it when leaving the overlay or resetting transparency.
|
||||
const previous = document.body.style.backgroundColor;
|
||||
document.body.style.backgroundColor = 'transparent';
|
||||
return () => { document.body.style.backgroundColor = previous; };
|
||||
}, [backgroundAlpha]);
|
||||
const { camera, name, model, filename, status, state, progress, layers, remaining, eta, temperatures } =
|
||||
props;
|
||||
const progressTextStyle = overlayProgressTextStyle(props.progressBackground);
|
||||
const hasPanel =
|
||||
filename || status || progress != null || layers || remaining || eta || temperatures.length > 0;
|
||||
const rotation = camera?.rotation ?? 0;
|
||||
@@ -65,7 +88,7 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
|
||||
].filter((stat) => stat.value != null);
|
||||
|
||||
return (
|
||||
<div className="updated-overlay" data-size={props.size} data-state={state}>
|
||||
<div className="updated-overlay" data-size={props.size} data-state={state} style={backgroundStyle}>
|
||||
<header className="updated-overlay__header">
|
||||
{(name || model) && (
|
||||
<div className="updated-overlay__identity">
|
||||
@@ -76,14 +99,16 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
className="updated-overlay__logo"
|
||||
href="https://github.com/maziggy/bambuddy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img src="/img/bambuddy_logo_powered_by.png" alt="Bambuddy" />
|
||||
</a>
|
||||
<div className="updated-overlay__logo flex flex-col items-end">
|
||||
{props.customLogo}
|
||||
<a
|
||||
href="https://github.com/maziggy/bambuddy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img src="/img/bambuddy_logo_powered_by.png" alt="Bambuddy" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
{camera && (
|
||||
<div className="updated-overlay__camera" ref={cameraRef}>
|
||||
@@ -116,7 +141,7 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
|
||||
)}
|
||||
{progress != null && (
|
||||
<div className="updated-overlay__progress">
|
||||
<span>{t('streamOverlay.progress')}</span>
|
||||
<span style={progressTextStyle}>{t('streamOverlay.progress')}</span>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={t('streamOverlay.progress')}
|
||||
@@ -124,9 +149,9 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={progress}
|
||||
>
|
||||
<div style={{ width: `${progress}%` }} />
|
||||
<div style={{ width: `${progress}%`, background: props.progressBackground }} />
|
||||
</div>
|
||||
<strong>{Math.round(progress)}%</strong>
|
||||
<strong style={progressTextStyle}>{Math.round(progress)}%</strong>
|
||||
</div>
|
||||
)}
|
||||
{(stats.length > 0 || temperatures.length > 0) && (
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export function useOverlayLogo(enabled: boolean, token: string | null = null, revision = 0) {
|
||||
const [logo, setLogo] = useState<{ url: string; token: string | null; revision: number } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
const controller = new AbortController();
|
||||
let url: string | undefined;
|
||||
void api.getOverlayLogo(token, controller.signal).then((blob) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (blob) {
|
||||
url = URL.createObjectURL(blob);
|
||||
setLogo({ url, token, revision });
|
||||
} else {
|
||||
setLogo(null);
|
||||
}
|
||||
}).catch(() => {
|
||||
if (!controller.signal.aborted) setLogo(null);
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [enabled, token, revision]);
|
||||
return enabled && logo?.token === token && logo.revision === revision ? logo.url : null;
|
||||
}
|
||||
@@ -3610,6 +3610,16 @@ export default {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: 'Markenauftritt',
|
||||
hint: 'Ein gemeinsames Logo für diese Installation. PNG oder WebP, bis zu 2 MiB und 4 Millionen Pixel. Ersetzen oder Entfernen betrifft alle Overlays, die es verwenden.',
|
||||
upload: 'Logo hochladen',
|
||||
logo: 'Eigenes Logo',
|
||||
from: 'Startfarbe',
|
||||
to: 'Endfarbe',
|
||||
reset: 'Farben zurücksetzen',
|
||||
failed: 'Logo konnte nicht gespeichert werden. Bildformat und Größe prüfen.',
|
||||
},
|
||||
title: 'Stream-Overlay',
|
||||
invalidPrinterId: 'Ungültige Drucker-ID',
|
||||
cameraStream: 'Kamera-Stream',
|
||||
@@ -3620,6 +3630,8 @@ export default {
|
||||
layer: 'Schicht',
|
||||
remaining: 'Verbleibend',
|
||||
builder: {
|
||||
backgroundTransparency: 'Hintergrundtransparenz',
|
||||
backgroundTransparencyHint: 'Nur die dunklen Hintergründe ausblenden. Text, Logos und Kamera bleiben sichtbar.',
|
||||
artwork: 'Gestaltung',
|
||||
artworkClassic: 'Klassisch',
|
||||
artworkV2: 'Version 2',
|
||||
|
||||
@@ -3640,6 +3640,16 @@ export default {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: 'Branding',
|
||||
hint: 'One shared logo for this installation. PNG or WebP, up to 2 MiB and 4 million pixels. Replacing or removing it affects overlays using it.',
|
||||
upload: 'Upload logo',
|
||||
logo: 'Custom logo',
|
||||
from: 'From colour',
|
||||
to: 'To colour',
|
||||
reset: 'Reset colours',
|
||||
failed: 'Could not save the logo. Check the image format and size.',
|
||||
},
|
||||
title: 'Stream Overlay',
|
||||
invalidPrinterId: 'Invalid printer ID',
|
||||
cameraStream: 'Camera stream',
|
||||
@@ -3650,6 +3660,8 @@ export default {
|
||||
layer: 'Layer',
|
||||
remaining: 'Remaining',
|
||||
builder: {
|
||||
backgroundTransparency: 'Background transparency',
|
||||
backgroundTransparencyHint: 'Fade the dark backgrounds only. Text, logos and camera stay visible.',
|
||||
artwork: 'Artwork',
|
||||
artworkClassic: 'Classic',
|
||||
artworkV2: 'Version 2',
|
||||
|
||||
@@ -3612,6 +3612,16 @@ export default {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: 'Identidad visual',
|
||||
hint: 'Un logotipo compartido para esta instalación. PNG o WebP, hasta 2 MiB y 4 millones de píxeles. Reemplazarlo o eliminarlo afecta a las superposiciones que lo utilizan.',
|
||||
upload: 'Subir logotipo',
|
||||
logo: 'Logotipo personalizado',
|
||||
from: 'Color inicial',
|
||||
to: 'Color final',
|
||||
reset: 'Restablecer colores',
|
||||
failed: 'No se pudo guardar el logotipo. Comprueba el formato y el tamaño.',
|
||||
},
|
||||
title: 'Superposición de transmisión',
|
||||
invalidPrinterId: 'ID de impresora no válido',
|
||||
cameraStream: 'Transmisión de la cámara',
|
||||
@@ -3622,6 +3632,8 @@ export default {
|
||||
layer: 'Capa',
|
||||
remaining: 'Restante',
|
||||
builder: {
|
||||
backgroundTransparency: 'Transparencia del fondo',
|
||||
backgroundTransparencyHint: 'Atenúa solo los fondos oscuros. El texto, los logotipos y la cámara siguen visibles.',
|
||||
artwork: 'Diseño',
|
||||
artworkClassic: 'Clásico',
|
||||
artworkV2: 'Versión 2',
|
||||
|
||||
@@ -3598,6 +3598,16 @@ export default {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: 'Identité visuelle',
|
||||
hint: 'Un logo partagé pour cette installation. PNG ou WebP, jusqu’à 2 Mio et 4 millions de pixels. Son remplacement ou sa suppression affecte les incrustations qui l’utilisent.',
|
||||
upload: 'Importer un logo',
|
||||
logo: 'Logo personnalisé',
|
||||
from: 'Couleur de départ',
|
||||
to: 'Couleur de fin',
|
||||
reset: 'Réinitialiser les couleurs',
|
||||
failed: 'Impossible d’enregistrer le logo. Vérifiez le format et la taille.',
|
||||
},
|
||||
title: 'Superposition Flux',
|
||||
invalidPrinterId: 'ID invalide',
|
||||
cameraStream: 'Flux caméra',
|
||||
@@ -3608,6 +3618,8 @@ export default {
|
||||
layer: 'Couche',
|
||||
remaining: 'Temps restant',
|
||||
builder: {
|
||||
backgroundTransparency: 'Transparence du fond',
|
||||
backgroundTransparencyHint: 'Estompe uniquement les fonds sombres. Le texte, les logos et la caméra restent visibles.',
|
||||
artwork: 'Habillage',
|
||||
artworkClassic: 'Classique',
|
||||
artworkV2: 'Version 2',
|
||||
|
||||
@@ -3597,6 +3597,16 @@ export default {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: 'Identità visiva',
|
||||
hint: 'Un logo condiviso per questa installazione. PNG o WebP, fino a 2 MiB e 4 milioni di pixel. Sostituirlo o rimuoverlo influisce sulle sovrapposizioni che lo usano.',
|
||||
upload: 'Carica logo',
|
||||
logo: 'Logo personalizzato',
|
||||
from: 'Colore iniziale',
|
||||
to: 'Colore finale',
|
||||
reset: 'Ripristina colori',
|
||||
failed: 'Impossibile salvare il logo. Controlla formato e dimensioni.',
|
||||
},
|
||||
title: 'Overlay stream',
|
||||
invalidPrinterId: 'ID stampante non valido',
|
||||
cameraStream: 'Stream camera',
|
||||
@@ -3607,6 +3617,8 @@ export default {
|
||||
layer: 'Strato',
|
||||
remaining: 'Tempo rimanente',
|
||||
builder: {
|
||||
backgroundTransparency: 'Trasparenza dello sfondo',
|
||||
backgroundTransparencyHint: 'Sfuma solo gli sfondi scuri. Testo, loghi e videocamera restano visibili.',
|
||||
artwork: 'Grafica',
|
||||
artworkClassic: 'Classica',
|
||||
artworkV2: 'Versione 2',
|
||||
|
||||
@@ -3610,6 +3610,16 @@ export default {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: 'ブランド設定',
|
||||
hint: 'このインストールで共有するロゴです。PNG または WebP、最大 2 MiB、400 万画素。変更や削除は使用中のオーバーレイにも反映されます。',
|
||||
upload: 'ロゴをアップロード',
|
||||
logo: 'カスタムロゴ',
|
||||
from: '開始色',
|
||||
to: '終了色',
|
||||
reset: '色をリセット',
|
||||
failed: 'ロゴを保存できませんでした。画像の形式とサイズを確認してください。',
|
||||
},
|
||||
title: 'ストリームオーバーレイ',
|
||||
invalidPrinterId: '無効なプリンターID',
|
||||
cameraStream: 'カメラストリーム',
|
||||
@@ -3620,6 +3630,8 @@ export default {
|
||||
layer: 'レイヤー',
|
||||
remaining: '残り時間',
|
||||
builder: {
|
||||
backgroundTransparency: '背景の透明度',
|
||||
backgroundTransparencyHint: '暗い背景のみを薄くします。テキスト、ロゴ、カメラ映像はそのまま表示されます。',
|
||||
artwork: 'デザイン',
|
||||
artworkClassic: 'クラシック',
|
||||
artworkV2: 'バージョン 2',
|
||||
|
||||
@@ -3433,6 +3433,16 @@ export default {
|
||||
}
|
||||
},
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: '브랜딩 설정',
|
||||
hint: '이 설치에서 공유하는 로고입니다. PNG 또는 WebP, 최대 2 MiB 및 400만 픽셀. 교체하거나 삭제하면 사용 중인 오버레이에 영향을 줍니다.',
|
||||
upload: '로고 업로드',
|
||||
logo: '사용자 지정 로고',
|
||||
from: '시작 색상',
|
||||
to: '끝 색상',
|
||||
reset: '색상 초기화',
|
||||
failed: '로고를 저장하지 못했습니다. 이미지 형식과 크기를 확인하세요.',
|
||||
},
|
||||
title: '스트림 오버레이',
|
||||
invalidPrinterId: '유효하지 않은 프린터 ID',
|
||||
cameraStream: '카메라 스트림',
|
||||
@@ -3443,6 +3453,8 @@ export default {
|
||||
layer: '레이어',
|
||||
remaining: '남은 시간',
|
||||
builder: {
|
||||
backgroundTransparency: '배경 투명도',
|
||||
backgroundTransparencyHint: '어두운 배경만 흐리게 합니다. 텍스트, 로고와 카메라 영상은 그대로 표시됩니다.',
|
||||
artwork: '디자인',
|
||||
artworkClassic: '클래식',
|
||||
artworkV2: '버전 2',
|
||||
|
||||
@@ -3640,6 +3640,16 @@ export default {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: 'Huisstijl',
|
||||
hint: 'Eén gedeeld logo voor deze installatie. PNG of WebP, maximaal 2 MiB en 4 miljoen pixels. Vervangen of verwijderen heeft invloed op overlays die het gebruiken.',
|
||||
upload: 'Logo uploaden',
|
||||
logo: 'Eigen logo',
|
||||
from: 'Beginkleur',
|
||||
to: 'Eindkleur',
|
||||
reset: 'Kleuren herstellen',
|
||||
failed: 'Kan het logo niet opslaan. Controleer het formaat en de grootte.',
|
||||
},
|
||||
title: 'Streamoverlay',
|
||||
invalidPrinterId: 'Ongeldige printer-ID',
|
||||
cameraStream: 'Camerastream',
|
||||
@@ -3650,6 +3660,8 @@ export default {
|
||||
layer: 'Laag',
|
||||
remaining: 'Resterend',
|
||||
builder: {
|
||||
backgroundTransparency: 'Achtergrondtransparantie',
|
||||
backgroundTransparencyHint: 'Vervaag alleen de donkere achtergronden. Tekst, logo’s en camera blijven zichtbaar.',
|
||||
artwork: 'Vormgeving',
|
||||
artworkClassic: 'Klassiek',
|
||||
artworkV2: 'Versie 2',
|
||||
|
||||
@@ -3597,6 +3597,16 @@ export default {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: 'Identidade visual',
|
||||
hint: 'Um logotipo compartilhado nesta instalação. PNG ou WebP, até 2 MiB e 4 milhões de pixels. Substituir ou remover afeta as sobreposições que o utilizam.',
|
||||
upload: 'Enviar logotipo',
|
||||
logo: 'Logotipo personalizado',
|
||||
from: 'Cor inicial',
|
||||
to: 'Cor final',
|
||||
reset: 'Redefinir cores',
|
||||
failed: 'Não foi possível salvar o logotipo. Verifique o formato e o tamanho.',
|
||||
},
|
||||
title: 'Sobreposição de stream',
|
||||
invalidPrinterId: 'ID da impressora inválido',
|
||||
cameraStream: 'Transmissão da câmera',
|
||||
@@ -3607,6 +3617,8 @@ export default {
|
||||
layer: 'Camada',
|
||||
remaining: 'Tempo restante',
|
||||
builder: {
|
||||
backgroundTransparency: 'Transparência do fundo',
|
||||
backgroundTransparencyHint: 'Atenua apenas os fundos escuros. Texto, logotipos e câmera continuam visíveis.',
|
||||
artwork: 'Visual',
|
||||
artworkClassic: 'Clássico',
|
||||
artworkV2: 'Versão 2',
|
||||
|
||||
@@ -3425,6 +3425,16 @@ export default {
|
||||
},
|
||||
},
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: "Оформление",
|
||||
hint: "Общий логотип для этой установки. PNG или WebP, до 2 МиБ и 4 миллионов пикселей. Замена или удаление влияет на использующие его оверлеи.",
|
||||
upload: "Загрузить логотип",
|
||||
logo: "Свой логотип",
|
||||
from: "Начальный цвет",
|
||||
to: "Конечный цвет",
|
||||
reset: "Сбросить цвета",
|
||||
failed: "Не удалось сохранить логотип. Проверьте формат и размер изображения.",
|
||||
},
|
||||
title: "Оформление трансляции",
|
||||
invalidPrinterId: "Неверный идентификатор принтера",
|
||||
cameraStream: "Видеопоток камеры",
|
||||
@@ -3435,6 +3445,8 @@ export default {
|
||||
layer: 'Слой',
|
||||
remaining: 'Осталось',
|
||||
builder: {
|
||||
backgroundTransparency: "Прозрачность фона",
|
||||
backgroundTransparencyHint: "Изменяет только тёмный фон. Текст, логотипы и изображение камеры остаются видимыми.",
|
||||
artwork: 'Оформление',
|
||||
artworkClassic: 'Классическое',
|
||||
artworkV2: 'Версия 2',
|
||||
|
||||
@@ -3639,6 +3639,16 @@ errors: {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: 'Profilering',
|
||||
hint: 'En delad logotyp för denna installation. PNG eller WebP, högst 2 MiB och 4 miljoner pixlar. Byte eller borttagning påverkar överlägg som använder den.',
|
||||
upload: 'Ladda upp logotyp',
|
||||
logo: 'Egen logotyp',
|
||||
from: 'Startfärg',
|
||||
to: 'Slutfärg',
|
||||
reset: 'Återställ färger',
|
||||
failed: 'Kunde inte spara logotypen. Kontrollera bildformat och storlek.',
|
||||
},
|
||||
title: 'Strömöverlägg',
|
||||
invalidPrinterId: 'Ogiltigt skrivar-ID',
|
||||
cameraStream: 'Kameraström',
|
||||
@@ -3649,6 +3659,8 @@ errors: {
|
||||
layer: 'Lager',
|
||||
remaining: 'Återstående',
|
||||
builder: {
|
||||
backgroundTransparency: 'Bakgrundens genomskinlighet',
|
||||
backgroundTransparencyHint: 'Tona bara ned de mörka bakgrunderna. Text, logotyper och kamerabild förblir synliga.',
|
||||
artwork: 'Utseende',
|
||||
artworkClassic: 'Klassisk',
|
||||
artworkV2: 'Version 2',
|
||||
|
||||
@@ -3612,6 +3612,16 @@ export default {
|
||||
|
||||
// Akış bindirmesi
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: 'Marka görünümü',
|
||||
hint: 'Bu kurulum için paylaşılan bir logo. PNG veya WebP, en fazla 2 MiB ve 4 milyon piksel. Değiştirmek veya kaldırmak, kullanan katmanları etkiler.',
|
||||
upload: 'Logo yükle',
|
||||
logo: 'Özel logo',
|
||||
from: 'Başlangıç rengi',
|
||||
to: 'Bitiş rengi',
|
||||
reset: 'Renkleri sıfırla',
|
||||
failed: 'Logo kaydedilemedi. Görüntü biçimini ve boyutunu kontrol edin.',
|
||||
},
|
||||
title: 'Akış Bindirmesi',
|
||||
invalidPrinterId: 'Geçersiz yazıcı ID\'si',
|
||||
cameraStream: 'Kamera akışı',
|
||||
@@ -3622,6 +3632,8 @@ export default {
|
||||
layer: 'Katman',
|
||||
remaining: 'Kalan süre',
|
||||
builder: {
|
||||
backgroundTransparency: 'Arka plan saydamlığı',
|
||||
backgroundTransparencyHint: 'Yalnızca koyu arka planları soldurur. Metin, logolar ve kamera görünür kalır.',
|
||||
artwork: 'Tasarım',
|
||||
artworkClassic: 'Klasik',
|
||||
artworkV2: 'Sürüm 2',
|
||||
|
||||
@@ -3637,6 +3637,16 @@ export default {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: "Оформлення",
|
||||
hint: "Спільний логотип для цієї інсталяції. PNG або WebP, до 2 МіБ і 4 мільйонів пікселів. Заміна або видалення впливає на оверлеї, які його використовують.",
|
||||
upload: "Завантажити логотип",
|
||||
logo: "Власний логотип",
|
||||
from: "Початковий колір",
|
||||
to: "Кінцевий колір",
|
||||
reset: "Скинути кольори",
|
||||
failed: "Не вдалося зберегти логотип. Перевірте формат і розмір зображення.",
|
||||
},
|
||||
title: "Накладання потоку",
|
||||
invalidPrinterId: "Недійсний ідентифікатор принтера",
|
||||
cameraStream: "Потік камери",
|
||||
@@ -3647,6 +3657,8 @@ export default {
|
||||
layer: 'Шар',
|
||||
remaining: 'Залишилось',
|
||||
builder: {
|
||||
backgroundTransparency: "Прозорість тла",
|
||||
backgroundTransparencyHint: "Змінює лише темне тло. Текст, логотипи та зображення камери залишаються видимими.",
|
||||
artwork: 'Оформлення',
|
||||
artworkClassic: 'Класичне',
|
||||
artworkV2: 'Версія 2',
|
||||
|
||||
@@ -3597,6 +3597,16 @@ export default {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: '品牌设置',
|
||||
hint: '此安装共享一个徽标。支持 PNG 或 WebP,最大 2 MiB 和 400 万像素。替换或删除会影响使用该徽标的叠加层。',
|
||||
upload: '上传徽标',
|
||||
logo: '自定义徽标',
|
||||
from: '起始颜色',
|
||||
to: '结束颜色',
|
||||
reset: '重置颜色',
|
||||
failed: '无法保存徽标。请检查图片格式和大小。',
|
||||
},
|
||||
title: '流叠加层',
|
||||
invalidPrinterId: '无效的打印机 ID',
|
||||
cameraStream: '摄像头流',
|
||||
@@ -3607,6 +3617,8 @@ export default {
|
||||
layer: '层数',
|
||||
remaining: '剩余时间',
|
||||
builder: {
|
||||
backgroundTransparency: '背景透明度',
|
||||
backgroundTransparencyHint: '仅淡化深色背景。文字、徽标和摄像头画面保持可见。',
|
||||
artwork: '外观',
|
||||
artworkClassic: '经典',
|
||||
artworkV2: '第 2 版',
|
||||
|
||||
@@ -3597,6 +3597,16 @@ export default {
|
||||
|
||||
// Stream overlay
|
||||
streamOverlay: {
|
||||
branding: {
|
||||
title: '品牌設定',
|
||||
hint: '此安裝共用一個標誌。支援 PNG 或 WebP,最大 2 MiB 和 400 萬像素。替換或刪除會影響使用該標誌的覆蓋層。',
|
||||
upload: '上傳標誌',
|
||||
logo: '自訂標誌',
|
||||
from: '起始顏色',
|
||||
to: '結束顏色',
|
||||
reset: '重設顏色',
|
||||
failed: '無法儲存標誌。請檢查圖片格式和大小。',
|
||||
},
|
||||
title: '流疊加層',
|
||||
invalidPrinterId: '無效的印表機 ID',
|
||||
cameraStream: '攝影機流',
|
||||
@@ -3607,6 +3617,8 @@ export default {
|
||||
layer: '層數',
|
||||
remaining: '剩餘時間',
|
||||
builder: {
|
||||
backgroundTransparency: '背景透明度',
|
||||
backgroundTransparencyHint: '僅淡化深色背景。文字、標誌和攝影機畫面保持可見。',
|
||||
artwork: '外觀',
|
||||
artworkClassic: '經典',
|
||||
artworkV2: '第 2 版',
|
||||
|
||||
@@ -3,6 +3,8 @@ import { useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Layers, Clock, Timer, Printer, Flame, Square, Box } from 'lucide-react';
|
||||
import { useOverlayLogo } from '../hooks/useOverlayLogo';
|
||||
import { overlayGradient, overlayProgressTextStyle } from '../utils/overlayBranding';
|
||||
import { UpdatedStreamOverlay } from '../components/UpdatedStreamOverlay';
|
||||
import { api, ApiError, withStreamToken } from '../api/client';
|
||||
import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
|
||||
@@ -16,6 +18,7 @@ type OverlaySize = 'small' | 'medium' | 'large';
|
||||
interface OverlayConfig {
|
||||
size: OverlaySize;
|
||||
updatedArtwork: boolean;
|
||||
backgroundTransparency: number;
|
||||
fps: number;
|
||||
showCamera: boolean;
|
||||
showProgress: boolean;
|
||||
@@ -54,7 +57,13 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
|
||||
const cameraParam = params.get('camera');
|
||||
const showCamera = cameraParam !== 'false' && cameraParam !== '0';
|
||||
|
||||
const transparencyParam = Number(params.get('backgroundTransparency'));
|
||||
const backgroundTransparency = Number.isFinite(transparencyParam)
|
||||
? Math.min(100, Math.max(0, transparencyParam))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
backgroundTransparency,
|
||||
size: (params.get('size') as OverlaySize) || 'medium',
|
||||
fps,
|
||||
updatedArtwork: params.get('artwork') === '2',
|
||||
@@ -178,6 +187,16 @@ export function StreamOverlayPage() {
|
||||
// (status + camera stream) is authenticated by that token instead of a JWT.
|
||||
const token = searchParams.get('token');
|
||||
const kiosk = token != null && token !== '';
|
||||
const logo = useOverlayLogo(searchParams.get('logo') === '1', token);
|
||||
const progressBackground = overlayGradient(searchParams.get('progressFrom'), searchParams.get('progressTo'));
|
||||
const progressTextStyle = overlayProgressTextStyle(progressBackground);
|
||||
const customLogo = logo ? (
|
||||
<img
|
||||
src={logo}
|
||||
alt={t('streamOverlay.branding.logo')}
|
||||
className="mb-2 max-h-[14vh] max-w-[20vw] object-contain"
|
||||
/>
|
||||
) : null;
|
||||
|
||||
// Kiosk path: one token-authenticated call for name + live status + the one
|
||||
// setting the overlay reads. No JWT, so this is the only feed available.
|
||||
@@ -382,6 +401,9 @@ export function StreamOverlayPage() {
|
||||
const remainingTime = status.remaining_time;
|
||||
const hasRemaining = active && config.showEta && remainingTime != null && remainingTime > 0;
|
||||
return <UpdatedStreamOverlay
|
||||
backgroundTransparency={config.backgroundTransparency}
|
||||
customLogo={customLogo}
|
||||
progressBackground={progressBackground}
|
||||
size={config.size}
|
||||
camera={config.showCamera ? { url: streamUrl, rotation: printer?.camera_rotation ?? 0, onError: handleStreamError } : null}
|
||||
name={config.showPrinter ? printer?.name ?? null : null}
|
||||
@@ -411,19 +433,21 @@ export function StreamOverlayPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Bambuddy logo - top right */}
|
||||
<a
|
||||
href="https://github.com/maziggy/bambuddy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="absolute top-4 right-4 z-10"
|
||||
>
|
||||
<img
|
||||
src="/img/bambuddy_logo_dark_transparent.png"
|
||||
alt="Bambuddy"
|
||||
className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
|
||||
/>
|
||||
</a>
|
||||
{/* Channel branding above the Bambuddy mark. */}
|
||||
<div className="absolute top-4 right-4 z-10 flex flex-col items-end">
|
||||
{customLogo}
|
||||
<a
|
||||
href="https://github.com/maziggy/bambuddy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<img
|
||||
src="/img/bambuddy_logo_dark_transparent.png"
|
||||
alt="Bambuddy"
|
||||
className={`${sizes.logoHeight} object-contain drop-shadow-lg hover:scale-105 transition-transform`}
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Status overlay - bottom */}
|
||||
<div className="absolute bottom-0 left-0 right-0 z-10 bg-gradient-to-t from-black/80 via-black/60 to-transparent">
|
||||
@@ -454,13 +478,13 @@ export function StreamOverlayPage() {
|
||||
{config.showProgress && isPrinting && (
|
||||
<div className="mb-3">
|
||||
<div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
|
||||
<span className="text-white/70">{t('streamOverlay.progress')}</span>
|
||||
<span className="text-white font-bold">{Math.round(progress)}%</span>
|
||||
<span className="text-white/70" style={progressTextStyle}>{t('streamOverlay.progress')}</span>
|
||||
<span className="text-white font-bold" style={progressTextStyle}>{Math.round(progress)}%</span>
|
||||
</div>
|
||||
<div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
|
||||
<div
|
||||
className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
|
||||
style={{ width: `${progress}%` }}
|
||||
style={{ width: `${progress}%`, background: progressBackground }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
|
||||
export interface OverlayBranding {
|
||||
logo: boolean;
|
||||
logoRevision: number;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_BRANDING: OverlayBranding = { logo: false, logoRevision: 0, from: '', to: '' };
|
||||
|
||||
// Only six-digit hex colours can reach CSS. Invalid or missing pairs preserve
|
||||
// the artwork's existing progress colour, including its state-specific styling.
|
||||
export function isOverlayColour(value: string): boolean {
|
||||
return /^#[0-9a-f]{6}$/i.test(value);
|
||||
}
|
||||
|
||||
export function overlayGradient(from: string | null, to: string | null): string | undefined {
|
||||
return from && to && isOverlayColour(from) && isOverlayColour(to)
|
||||
? `linear-gradient(to right, ${from}, ${to})`
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function overlayProgressTextStyle(background: string | undefined): CSSProperties | undefined {
|
||||
if (!background) return undefined;
|
||||
return {
|
||||
backgroundImage: background,
|
||||
backgroundClip: 'text',
|
||||
WebkitBackgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user