Browse Source

Add stream overlay logos and progress colours (#3208)

Adam Spice 8 hours ago
parent
commit
67d3e7e869
32 changed files with 933 additions and 101 deletions
  1. 86 0
      backend/app/api/routes/overlay_branding.py
  2. 2 0
      backend/app/api/routes/settings.py
  3. 4 0
      backend/app/main.py
  4. 43 1
      backend/tests/integration/test_backup_manifest.py
  5. 88 0
      backend/tests/integration/test_overlay_branding.py
  6. 28 53
      backend/tests/integration/test_overlay_status_api.py
  7. 30 0
      backend/tests/overlay_helpers.py
  8. 127 7
      frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx
  9. 40 0
      frontend/src/__tests__/pages/StreamOverlayPage.test.tsx
  10. 27 0
      frontend/src/api/client.ts
  11. 77 0
      frontend/src/components/OverlayBrandingControls.tsx
  12. 57 5
      frontend/src/components/StreamOverlayBuilder.tsx
  13. 6 6
      frontend/src/components/UpdatedStreamOverlay.css
  14. 38 13
      frontend/src/components/UpdatedStreamOverlay.tsx
  15. 27 0
      frontend/src/hooks/useOverlayLogo.ts
  16. 12 0
      frontend/src/i18n/locales/de.ts
  17. 12 0
      frontend/src/i18n/locales/en.ts
  18. 12 0
      frontend/src/i18n/locales/es.ts
  19. 12 0
      frontend/src/i18n/locales/fr.ts
  20. 12 0
      frontend/src/i18n/locales/it.ts
  21. 12 0
      frontend/src/i18n/locales/ja.ts
  22. 12 0
      frontend/src/i18n/locales/ko.ts
  23. 12 0
      frontend/src/i18n/locales/nl.ts
  24. 12 0
      frontend/src/i18n/locales/pt-BR.ts
  25. 12 0
      frontend/src/i18n/locales/ru.ts
  26. 12 0
      frontend/src/i18n/locales/sv.ts
  27. 12 0
      frontend/src/i18n/locales/tr.ts
  28. 12 0
      frontend/src/i18n/locales/uk.ts
  29. 12 0
      frontend/src/i18n/locales/zh-CN.ts
  30. 12 0
      frontend/src/i18n/locales/zh-TW.ts
  31. 40 16
      frontend/src/pages/StreamOverlayPage.tsx
  32. 33 0
      frontend/src/utils/overlayBranding.ts

+ 86 - 0
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"}

+ 2 - 0
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 = []

+ 4 - 0
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)

+ 43 - 1
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()

+ 88 - 0
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"<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

+ 28 - 53
backend/tests/integration/test_overlay_status_api.py

@@ -14,34 +14,9 @@ from __future__ import annotations
 import pytest
 from httpx import AsyncClient
 
-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"]
-
+from backend.tests.overlay_helpers import mint_token, setup_admin
 
-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"]
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
 
 
 @pytest.fixture
@@ -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

+ 30 - 0
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"]

+ 127 - 7
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(<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();
+});

+ 40 - 0
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,

+ 27 - 0
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<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) =>

+ 77 - 0
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 <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>;
+}

+ 57 - 5
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() {
           </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"
+  />;
+}

+ 6 - 6
frontend/src/components/UpdatedStreamOverlay.css

@@ -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;

+ 38 - 13
frontend/src/components/UpdatedStreamOverlay.tsx

@@ -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) && (

+ 27 - 0
frontend/src/hooks/useOverlayLogo.ts

@@ -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;
+}

+ 12 - 0
frontend/src/i18n/locales/de.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/en.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/es.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/fr.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/it.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/ja.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/ko.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/nl.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/ru.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/sv.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/tr.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/uk.ts

@@ -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',

+ 12 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -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 版',

+ 12 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -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 版',

+ 40 - 16
frontend/src/pages/StreamOverlayPage.tsx

@@ -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>

+ 33 - 0
frontend/src/utils/overlayBranding.ts

@@ -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',
+  };
+}