From 67d3e7e869239e42488858855438783c7ece0b5d Mon Sep 17 00:00:00 2001 From: Adam Spice Date: Sat, 3 Oct 2026 20:33:13 +1300 Subject: [PATCH] Add stream overlay logos and progress colours (#3208) --- backend/app/api/routes/overlay_branding.py | 86 +++++++++++ backend/app/api/routes/settings.py | 2 + backend/app/main.py | 4 + .../tests/integration/test_backup_manifest.py | 44 +++++- .../integration/test_overlay_branding.py | 88 ++++++++++++ .../integration/test_overlay_status_api.py | 81 ++++------- backend/tests/overlay_helpers.py | 30 ++++ .../components/StreamOverlayBuilder.test.tsx | 134 +++++++++++++++++- .../pages/StreamOverlayPage.test.tsx | 40 ++++++ frontend/src/api/client.ts | 27 ++++ .../components/OverlayBrandingControls.tsx | 77 ++++++++++ .../src/components/StreamOverlayBuilder.tsx | 62 +++++++- .../src/components/UpdatedStreamOverlay.css | 12 +- .../src/components/UpdatedStreamOverlay.tsx | 51 +++++-- frontend/src/hooks/useOverlayLogo.ts | 27 ++++ frontend/src/i18n/locales/de.ts | 12 ++ frontend/src/i18n/locales/en.ts | 12 ++ frontend/src/i18n/locales/es.ts | 12 ++ frontend/src/i18n/locales/fr.ts | 12 ++ frontend/src/i18n/locales/it.ts | 12 ++ frontend/src/i18n/locales/ja.ts | 12 ++ frontend/src/i18n/locales/ko.ts | 12 ++ frontend/src/i18n/locales/nl.ts | 12 ++ frontend/src/i18n/locales/pt-BR.ts | 12 ++ frontend/src/i18n/locales/ru.ts | 12 ++ frontend/src/i18n/locales/sv.ts | 12 ++ frontend/src/i18n/locales/tr.ts | 12 ++ frontend/src/i18n/locales/uk.ts | 12 ++ frontend/src/i18n/locales/zh-CN.ts | 12 ++ frontend/src/i18n/locales/zh-TW.ts | 12 ++ frontend/src/pages/StreamOverlayPage.tsx | 56 +++++--- frontend/src/utils/overlayBranding.ts | 33 +++++ 32 files changed, 933 insertions(+), 101 deletions(-) create mode 100644 backend/app/api/routes/overlay_branding.py create mode 100644 backend/tests/integration/test_overlay_branding.py create mode 100644 backend/tests/overlay_helpers.py create mode 100644 frontend/src/components/OverlayBrandingControls.tsx create mode 100644 frontend/src/hooks/useOverlayLogo.ts create mode 100644 frontend/src/utils/overlayBranding.ts diff --git a/backend/app/api/routes/overlay_branding.py b/backend/app/api/routes/overlay_branding.py new file mode 100644 index 000000000..99551d0b7 --- /dev/null +++ b/backend/app/api/routes/overlay_branding.py @@ -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"} diff --git a/backend/app/api/routes/settings.py b/backend/app/api/routes/settings.py index 7fd2c0a93..e40cd40a6 100644 --- a/backend/app/api/routes/settings.py +++ b/backend/app/api/routes/settings.py @@ -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 = [] diff --git a/backend/app/main.py b/backend/app/main.py index a643552c1..374bb2eaf 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/tests/integration/test_backup_manifest.py b/backend/tests/integration/test_backup_manifest.py index 7a43f8ff9..95c666014 100644 --- a/backend/tests/integration/test_backup_manifest.py +++ b/backend/tests/integration/test_backup_manifest.py @@ -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() diff --git a/backend/tests/integration/test_overlay_branding.py b/backend/tests/integration/test_overlay_branding.py new file mode 100644 index 000000000..018326ccf --- /dev/null +++ b/backend/tests/integration/test_overlay_branding.py @@ -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"", 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 diff --git a/backend/tests/integration/test_overlay_status_api.py b/backend/tests/integration/test_overlay_status_api.py index dada92891..0c5becf49 100644 --- a/backend/tests/integration/test_overlay_status_api.py +++ b/backend/tests/integration/test_overlay_status_api.py @@ -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 diff --git a/backend/tests/overlay_helpers.py b/backend/tests/overlay_helpers.py new file mode 100644 index 000000000..c4c345896 --- /dev/null +++ b/backend/tests/overlay_helpers.py @@ -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"] diff --git a/frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx b/frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx index 3026ba432..825cc825b 100644 --- a/frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx +++ b/frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); +}); diff --git a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx index f25468d1d..6a8f9abe3 100644 --- a/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx +++ b/frontend/src/__tests__/pages/StreamOverlayPage.test.tsx @@ -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, diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index aa2932d38..733e7b850 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -4617,6 +4617,33 @@ export interface AuthStatus { // API functions export const api = { + // Overlay branding + getOverlayLogo: async (token: string | null, signal?: AbortSignal): Promise => { + 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 => { + 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('/auth/status'), setupAuth: (data: SetupRequest) => diff --git a/frontend/src/components/OverlayBrandingControls.tsx b/frontend/src/components/OverlayBrandingControls.tsx new file mode 100644 index 000000000..d318a002a --- /dev/null +++ b/frontend/src/components/OverlayBrandingControls.tsx @@ -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
+ {t('streamOverlay.branding.title')} +

{t('streamOverlay.branding.hint')}

+ + {logo &&
+ {t('streamOverlay.branding.logo')} + + +
} +
+ {(['from', 'to'] as const).map((key) =>
+ +
+ setColour(key, event.target.value)} className="h-9 w-12 bg-transparent" /> + 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" /> +
+
)} +
+ +
; +} diff --git a/frontend/src/components/StreamOverlayBuilder.tsx b/frontend/src/components/StreamOverlayBuilder.tsx index 02be20e4a..7006227fc 100644 --- a/frontend/src/components/StreamOverlayBuilder.tsx +++ b/frontend/src/components/StreamOverlayBuilder.tsx @@ -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() { + {artwork === '2' && ( +
+ + setBackgroundTransparency(Number(event.target.value))} + className="w-full accent-bambu-green" + /> +

{t('streamOverlay.builder.backgroundTransparencyHint')}

+
+ )} +