Browse Source

Add stream overlay logos and progress colours (#3208)

Adam Spice 1 day 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),
             ("plate_calibration", app_settings.plate_calibration_dir),
             ("icons", base_dir / "icons"),
             ("icons", base_dir / "icons"),
             ("projects", base_dir / "projects"),
             ("projects", base_dir / "projects"),
+            ("overlay-branding", base_dir / "overlay-branding"),
         ]
         ]
 
 
         for name, src_dir in dirs_to_backup:
         for name, src_dir in dirs_to_backup:
@@ -1515,6 +1516,7 @@ async def restore_backup(
                 ("plate_calibration", app_settings.plate_calibration_dir),
                 ("plate_calibration", app_settings.plate_calibration_dir),
                 ("icons", base_dir / "icons"),
                 ("icons", base_dir / "icons"),
                 ("projects", base_dir / "projects"),
                 ("projects", base_dir / "projects"),
+                ("overlay-branding", base_dir / "overlay-branding"),
             ]
             ]
 
 
             skipped_dirs = []
             skipped_dirs = []

+ 4 - 0
backend/app/main.py

@@ -56,6 +56,7 @@ from backend.app.api.routes import (
     notifications,
     notifications,
     obico,
     obico,
     orca_cloud,
     orca_cloud,
+    overlay_branding,
     pending_uploads,
     pending_uploads,
     pipeline_runs,
     pipeline_runs,
     print_log,
     print_log,
@@ -10181,6 +10182,8 @@ PUBLIC_API_ROUTES = {
     # rejects an absent, expired, revoked, or wrong-scoped token. In particular a
     # rejects an absent, expired, revoked, or wrong-scoped token. In particular a
     # plain ``camera_stream`` token does NOT open this door.
     # plain ``camera_stream`` token does NOT open this door.
     "/api/v1/camwall/printers",
     "/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)
 # 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(inventory.router, prefix=app_settings.api_prefix)
 app.include_router(labels.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(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(cloud.router, prefix=app_settings.api_prefix)
 app.include_router(orca_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)
 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 sqlite3
 import zipfile
 import zipfile
 from pathlib import Path
 from pathlib import Path
-from unittest.mock import patch
+from unittest.mock import AsyncMock, patch
 
 
 import pytest
 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 "99.9.9" in detail
     assert APP_VERSION in detail
     assert APP_VERSION in detail
     assert "Nothing has been changed" 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
 import pytest
 from httpx import AsyncClient
 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
 @pytest.fixture
@@ -67,12 +42,12 @@ async def printer_row(db_session):
 
 
 class TestOverlayFeedAuth:
 class TestOverlayFeedAuth:
     async def test_no_token_is_rejected(self, async_client: AsyncClient, printer_row):
     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")
         response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status")
         assert response.status_code == 401
         assert response.status_code == 401
 
 
     async def test_garbage_token_is_rejected(self, async_client: AsyncClient, printer_row):
     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")
         response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token=bblt_aaaaaaaa_nope")
         assert response.status_code == 401
         assert response.status_code == 401
 
 
@@ -81,8 +56,8 @@ class TestOverlayFeedAuth:
         acquire the live print status (and filename) just because a new feature
         acquire the live print status (and filename) just because a new feature
         shipped.
         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}")
         response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={stream_token}")
         assert response.status_code == 401
         assert response.status_code == 401
@@ -95,22 +70,22 @@ class TestOverlayFeedAuth:
         rejected here — otherwise every wall token silently gains filename
         rejected here — otherwise every wall token silently gains filename
         visibility.
         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}")
         response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={camwall_token}")
         assert response.status_code == 401
         assert response.status_code == 401
 
 
     async def test_overlay_token_reaches_the_feed(self, async_client: AsyncClient, printer_row):
     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}")
         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.status_code == 200, response.text
         assert response.json()["name"] == "Stream P1S"
         assert response.json()["name"] == "Stream P1S"
 
 
     async def test_revoked_overlay_token_is_rejected(self, async_client: AsyncClient, printer_row):
     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(
         created = await async_client.post(
             "/api/v1/auth/tokens",
             "/api/v1/auth/tokens",
             headers={"Authorization": f"Bearer {jwt}"},
             headers={"Authorization": f"Bearer {jwt}"},
@@ -132,8 +107,8 @@ class TestOverlayFeedPayload:
         that is what distinguishes the scope. Assert the exact key set so the
         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.
         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}")
         response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
         assert response.status_code == 200
         assert response.status_code == 200
@@ -165,8 +140,8 @@ class TestOverlayFeedPayload:
         """No MQTT client runs in tests, so the printer has no state — the
         """No MQTT client runs in tests, so the printer has no state — the
         overlay must render its offline state rather than erroring.
         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}")
         response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
         entry = response.json()
         entry = response.json()
@@ -210,8 +185,8 @@ class TestOverlayFeedPayload:
 
 
         monkeypatch.setattr(pm.printer_manager, "get_status", lambda _pid: _FakeState())
         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}")
         response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
         assert response.json()["model"] == "P1S"
         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):
     async def test_unknown_model_is_null(self, async_client: AsyncClient, printer_row, db_session):
         printer_row.model = None
         printer_row.model = None
         await db_session.commit()
         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}")
         response = await async_client.get(f"/api/v1/printers/{printer_row.id}/overlay-status?token={overlay_token}")
         assert response.status_code == 200
         assert response.status_code == 200
         assert response.json()["model"] is None
         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
         """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.
         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}")
         response = await async_client.get(f"/api/v1/printers/99999/overlay-status?token={overlay_token}")
         assert response.status_code == 404
         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):
     async def test_overlay_token_passes_the_camera_stream_gate(self, async_client: AsyncClient):
         from backend.app.core.auth import verify_camera_stream_token
         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
         assert await verify_camera_stream_token(overlay_token) is True
 
 
     async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
     async def test_overlay_gate_rejects_camera_stream_and_camwall(self, async_client: AsyncClient):
         from backend.app.core.auth import verify_overlay_token
         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(stream_token) is False
         assert await verify_overlay_token(camwall_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."""
         """Symmetric guard: the new scope must not widen the Cam Wall either."""
         from backend.app.core.auth import verify_camwall_token
         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
         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.
  * 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 userEvent from '@testing-library/user-event';
 import { http, HttpResponse } from 'msw';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
 import { render } from '../utils';
@@ -27,7 +27,7 @@ function shownUrl(): string {
 
 
 describe('StreamOverlayBuilder', () => {
 describe('StreamOverlayBuilder', () => {
   beforeEach(() => {
   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 () => {
   it('starts on the first printer with the overlay defaults', async () => {
@@ -60,12 +60,12 @@ describe('StreamOverlayBuilder', () => {
     await user.click(screen.getByLabelText('Printer name'));
     await user.click(screen.getByLabelText('Printer name'));
     expect(new URL(shownUrl()).searchParams.get('show')).toBe('printer,model,filename,status,progress,layers,eta');
     expect(new URL(shownUrl()).searchParams.get('show')).toBe('printer,model,filename,status,progress,layers,eta');
     await user.click(screen.getByRole('button', { name: 'Show preview' }));
     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(model);
     await user.click(screen.getByLabelText('Printer name'));
     await user.click(screen.getByLabelText('Printer name'));
     expect(shownUrl()).toBe(originalUrl);
     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 () => {
   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');
     await user.selectOptions(artwork, 'Version 2');
     expect(new URL(shownUrl()).searchParams.get('artwork')).toBe('2');
     expect(new URL(shownUrl()).searchParams.get('artwork')).toBe('2');
     await user.click(screen.getByRole('button', { name: 'Show preview' }));
     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');
     await user.selectOptions(artwork, 'Classic');
     expect(shownUrl()).toBe(original);
     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 () => {
   it('switches printer', async () => {
@@ -190,3 +210,103 @@ describe('StreamOverlayBuilder', () => {
     await waitFor(() => expect(shownUrl()).toContain('/overlay/1'));
     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();
     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) => {
   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({
     server.use(http.get('/api/v1/printers/:id/overlay-status', () => HttpResponse.json({
       ...mockStatusIdle, camera_rotation: 90,
       ...mockStatusIdle, camera_rotation: 90,

+ 27 - 0
frontend/src/api/client.ts

@@ -4617,6 +4617,33 @@ export interface AuthStatus {
 
 
 // API functions
 // API functions
 export const api = {
 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
   // Authentication
   getAuthStatus: () => request<AuthStatus>('/auth/status'),
   getAuthStatus: () => request<AuthStatus>('/auth/status'),
   setupAuth: (data: SetupRequest) =>
   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 { Copy, ExternalLink, Eye, EyeOff } from 'lucide-react';
 import { api, type Printer } from '../api/client';
 import { api, type Printer } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { useToast } from '../contexts/ToastContext';
+import { OverlayBrandingControls } from './OverlayBrandingControls';
+import { DEFAULT_BRANDING } from '../utils/overlayBranding';
 import { NumberInput } from './NumberInput';
 import { NumberInput } from './NumberInput';
 
 
 type OverlaySize = 'small' | 'medium' | 'large';
 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
   // '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.
   // name like "updated" that stops being true once there's a newer one.
   const [artwork, setArtwork] = useState<'1' | '2'>('1');
   const [artwork, setArtwork] = useState<'1' | '2'>('1');
+  const [backgroundTransparency, setBackgroundTransparency] = useState(0);
   const [showCamera, setShowCamera] = useState(true);
   const [showCamera, setShowCamera] = useState(true);
   const [token, setToken] = useState('');
   const [token, setToken] = useState('');
+  const [branding, setBranding] = useState(DEFAULT_BRANDING);
   const [preview, setPreview] = useState(false);
   const [preview, setPreview] = useState(false);
 
 
   useEffect(() => {
   useEffect(() => {
@@ -84,10 +88,18 @@ export function StreamOverlayBuilder() {
     if (size !== 'medium') params.set('size', size);
     if (size !== 'medium') params.set('size', size);
     if (fps !== DEFAULT_FPS) params.set('fps', String(fps));
     if (fps !== DEFAULT_FPS) params.set('fps', String(fps));
     if (artwork !== '1') params.set('artwork', artwork);
     if (artwork !== '1') params.set('artwork', artwork);
+    if (artwork === '2' && backgroundTransparency > 0) {
+      params.set('backgroundTransparency', String(backgroundTransparency));
+    }
     if (!showCamera) params.set('camera', 'false');
     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());
     if (token.trim()) params.set('token', token.trim());
     return `${window.location.origin}/overlay/${id}?${params.toString()}`;
     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) => {
   const toggleField = (key: string) => {
     setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
     setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
@@ -181,6 +193,27 @@ export function StreamOverlayBuilder() {
           </select>
           </select>
         </div>
         </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>
         <div>
           <label htmlFor="overlay-builder-fps" className="block text-sm font-medium text-white mb-1">
           <label htmlFor="overlay-builder-fps" className="block text-sm font-medium text-white mb-1">
             {t('streamOverlay.builder.fps', 'Frame rate')}
             {t('streamOverlay.builder.fps', 'Frame rate')}
@@ -257,6 +290,8 @@ export function StreamOverlayBuilder() {
         </p>
         </p>
       </fieldset>
       </fieldset>
 
 
+      <OverlayBrandingControls value={branding} onChange={setBranding} />
+
       <div className="mt-4">
       <div className="mt-4">
         <p className="text-sm font-medium text-white mb-1">
         <p className="text-sm font-medium text-white mb-1">
           {t('streamOverlay.builder.urlTitle', 'Overlay URL')}
           {t('streamOverlay.builder.urlTitle', 'Overlay URL')}
@@ -309,14 +344,31 @@ export function StreamOverlayBuilder() {
             : t('streamOverlay.builder.showPreview', 'Show preview')}
             : t('streamOverlay.builder.showPreview', 'Show preview')}
         </button>
         </button>
         {preview && (
         {preview && (
-          <iframe
-            key={url}
-            src={url}
+          <OverlayPreview
+            url={url}
+            logoRevision={branding.logoRevision}
             title={t('streamOverlay.builder.previewTitle', 'Overlay preview')}
             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>
     </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;
   justify-content: space-between;
   min-height: 100vh;
   min-height: 100vh;
   color: #fff;
   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));
   font-size: calc(clamp(14px, 2.2vmin, 28px) * var(--overlay-scale));
   line-height: 1.3;
   line-height: 1.3;
   font-variant-numeric: tabular-nums;
   font-variant-numeric: tabular-nums;
@@ -35,7 +35,7 @@
   min-width: 0;
   min-width: 0;
   padding: 0.65em 0.9em;
   padding: 0.65em 0.9em;
   border-radius: 0.7em;
   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 {
 .updated-overlay__identity svg {
   flex-shrink: 0;
   flex-shrink: 0;
@@ -58,7 +58,7 @@
   flex-shrink: 0;
   flex-shrink: 0;
   margin-left: auto;
   margin-left: auto;
 }
 }
-.updated-overlay__logo img {
+.updated-overlay__logo > a img {
   width: clamp(100px, 19vmin, 230px);
   width: clamp(100px, 19vmin, 230px);
   height: auto;
   height: auto;
 }
 }
@@ -79,7 +79,7 @@
 }
 }
 .updated-overlay__panel {
 .updated-overlay__panel {
   padding: var(--overlay-space);
   padding: var(--overlay-space);
-  background: rgb(12 19 23 / 88%);
+  background: rgb(12 19 23 / var(--overlay-panel-alpha, 0.88));
 }
 }
 .updated-overlay__summary {
 .updated-overlay__summary {
   display: flex;
   display: flex;
@@ -200,7 +200,7 @@
   .updated-overlay__header {
   .updated-overlay__header {
     grid-row: 1;
     grid-row: 1;
     align-items: center;
     align-items: center;
-    background: #10171b;
+    background: rgb(16 23 27 / var(--overlay-background-alpha, 1));
   }
   }
   .updated-overlay__identity {
   .updated-overlay__identity {
     background: none;
     background: none;
@@ -214,7 +214,7 @@
   }
   }
   .updated-overlay__panel {
   .updated-overlay__panel {
     grid-row: 3;
     grid-row: 3;
-    background: #10171b;
+    background: rgb(16 23 27 / var(--overlay-background-alpha, 1));
   }
   }
   .updated-overlay__summary {
   .updated-overlay__summary {
     flex-direction: column-reverse;
     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 { useTranslation } from 'react-i18next';
 import { Clock, Layers, Printer, Timer } from 'lucide-react';
 import { Clock, Layers, Printer, Timer } from 'lucide-react';
 import './UpdatedStreamOverlay.css';
 import './UpdatedStreamOverlay.css';
+import { overlayProgressTextStyle } from '../utils/overlayBranding';
 
 
 interface UpdatedStreamOverlayProps {
 interface UpdatedStreamOverlayProps {
+  backgroundTransparency?: number;
+  customLogo?: ReactNode;
+  progressBackground?: string;
   size: 'small' | 'medium' | 'large';
   size: 'small' | 'medium' | 'large';
   camera: { url: string; rotation: number; onError: () => void } | null;
   camera: { url: string; rotation: number; onError: () => void } | null;
   name: string | null;
   name: string | null;
@@ -50,8 +54,27 @@ function useCameraBox(ref: RefObject<HTMLDivElement | null>, active: boolean) {
 
 
 export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
 export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
   const { t } = useTranslation();
   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 } =
   const { camera, name, model, filename, status, state, progress, layers, remaining, eta, temperatures } =
     props;
     props;
+  const progressTextStyle = overlayProgressTextStyle(props.progressBackground);
   const hasPanel =
   const hasPanel =
     filename || status || progress != null || layers || remaining || eta || temperatures.length > 0;
     filename || status || progress != null || layers || remaining || eta || temperatures.length > 0;
   const rotation = camera?.rotation ?? 0;
   const rotation = camera?.rotation ?? 0;
@@ -65,7 +88,7 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
   ].filter((stat) => stat.value != null);
   ].filter((stat) => stat.value != null);
 
 
   return (
   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">
       <header className="updated-overlay__header">
         {(name || model) && (
         {(name || model) && (
           <div className="updated-overlay__identity">
           <div className="updated-overlay__identity">
@@ -76,14 +99,16 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
             </div>
             </div>
           </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>
       </header>
       {camera && (
       {camera && (
         <div className="updated-overlay__camera" ref={cameraRef}>
         <div className="updated-overlay__camera" ref={cameraRef}>
@@ -116,7 +141,7 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
           )}
           )}
           {progress != null && (
           {progress != null && (
             <div className="updated-overlay__progress">
             <div className="updated-overlay__progress">
-              <span>{t('streamOverlay.progress')}</span>
+              <span style={progressTextStyle}>{t('streamOverlay.progress')}</span>
               <div
               <div
                 role="progressbar"
                 role="progressbar"
                 aria-label={t('streamOverlay.progress')}
                 aria-label={t('streamOverlay.progress')}
@@ -124,9 +149,9 @@ export function UpdatedStreamOverlay(props: UpdatedStreamOverlayProps) {
                 aria-valuemax={100}
                 aria-valuemax={100}
                 aria-valuenow={progress}
                 aria-valuenow={progress}
               >
               >
-                <div style={{ width: `${progress}%` }} />
+                <div style={{ width: `${progress}%`, background: props.progressBackground }} />
               </div>
               </div>
-              <strong>{Math.round(progress)}%</strong>
+              <strong style={progressTextStyle}>{Math.round(progress)}%</strong>
             </div>
             </div>
           )}
           )}
           {(stats.length > 0 || temperatures.length > 0) && (
           {(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
   // Stream overlay
   streamOverlay: {
   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',
     title: 'Stream-Overlay',
     invalidPrinterId: 'Ungültige Drucker-ID',
     invalidPrinterId: 'Ungültige Drucker-ID',
     cameraStream: 'Kamera-Stream',
     cameraStream: 'Kamera-Stream',
@@ -3620,6 +3630,8 @@ export default {
     layer: 'Schicht',
     layer: 'Schicht',
     remaining: 'Verbleibend',
     remaining: 'Verbleibend',
     builder: {
     builder: {
+      backgroundTransparency: 'Hintergrundtransparenz',
+      backgroundTransparencyHint: 'Nur die dunklen Hintergründe ausblenden. Text, Logos und Kamera bleiben sichtbar.',
       artwork: 'Gestaltung',
       artwork: 'Gestaltung',
       artworkClassic: 'Klassisch',
       artworkClassic: 'Klassisch',
       artworkV2: 'Version 2',
       artworkV2: 'Version 2',

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

@@ -3640,6 +3640,16 @@ export default {
 
 
   // Stream overlay
   // Stream overlay
   streamOverlay: {
   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',
     title: 'Stream Overlay',
     invalidPrinterId: 'Invalid printer ID',
     invalidPrinterId: 'Invalid printer ID',
     cameraStream: 'Camera stream',
     cameraStream: 'Camera stream',
@@ -3650,6 +3660,8 @@ export default {
     layer: 'Layer',
     layer: 'Layer',
     remaining: 'Remaining',
     remaining: 'Remaining',
     builder: {
     builder: {
+      backgroundTransparency: 'Background transparency',
+      backgroundTransparencyHint: 'Fade the dark backgrounds only. Text, logos and camera stay visible.',
       artwork: 'Artwork',
       artwork: 'Artwork',
       artworkClassic: 'Classic',
       artworkClassic: 'Classic',
       artworkV2: 'Version 2',
       artworkV2: 'Version 2',

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

@@ -3612,6 +3612,16 @@ export default {
 
 
   // Stream overlay
   // Stream overlay
   streamOverlay: {
   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',
     title: 'Superposición de transmisión',
     invalidPrinterId: 'ID de impresora no válido',
     invalidPrinterId: 'ID de impresora no válido',
     cameraStream: 'Transmisión de la cámara',
     cameraStream: 'Transmisión de la cámara',
@@ -3622,6 +3632,8 @@ export default {
     layer: 'Capa',
     layer: 'Capa',
     remaining: 'Restante',
     remaining: 'Restante',
     builder: {
     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',
       artwork: 'Diseño',
       artworkClassic: 'Clásico',
       artworkClassic: 'Clásico',
       artworkV2: 'Versión 2',
       artworkV2: 'Versión 2',

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

@@ -3598,6 +3598,16 @@ export default {
 
 
   // Stream overlay
   // Stream overlay
   streamOverlay: {
   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',
     title: 'Superposition Flux',
     invalidPrinterId: 'ID invalide',
     invalidPrinterId: 'ID invalide',
     cameraStream: 'Flux caméra',
     cameraStream: 'Flux caméra',
@@ -3608,6 +3618,8 @@ export default {
     layer: 'Couche',
     layer: 'Couche',
     remaining: 'Temps restant',
     remaining: 'Temps restant',
     builder: {
     builder: {
+      backgroundTransparency: 'Transparence du fond',
+      backgroundTransparencyHint: 'Estompe uniquement les fonds sombres. Le texte, les logos et la caméra restent visibles.',
       artwork: 'Habillage',
       artwork: 'Habillage',
       artworkClassic: 'Classique',
       artworkClassic: 'Classique',
       artworkV2: 'Version 2',
       artworkV2: 'Version 2',

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

@@ -3597,6 +3597,16 @@ export default {
 
 
   // Stream overlay
   // Stream overlay
   streamOverlay: {
   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',
     title: 'Overlay stream',
     invalidPrinterId: 'ID stampante non valido',
     invalidPrinterId: 'ID stampante non valido',
     cameraStream: 'Stream camera',
     cameraStream: 'Stream camera',
@@ -3607,6 +3617,8 @@ export default {
     layer: 'Strato',
     layer: 'Strato',
     remaining: 'Tempo rimanente',
     remaining: 'Tempo rimanente',
     builder: {
     builder: {
+      backgroundTransparency: 'Trasparenza dello sfondo',
+      backgroundTransparencyHint: 'Sfuma solo gli sfondi scuri. Testo, loghi e videocamera restano visibili.',
       artwork: 'Grafica',
       artwork: 'Grafica',
       artworkClassic: 'Classica',
       artworkClassic: 'Classica',
       artworkV2: 'Versione 2',
       artworkV2: 'Versione 2',

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

@@ -3610,6 +3610,16 @@ export default {
 
 
   // Stream overlay
   // Stream overlay
   streamOverlay: {
   streamOverlay: {
+    branding: {
+      title: 'ブランド設定',
+      hint: 'このインストールで共有するロゴです。PNG または WebP、最大 2 MiB、400 万画素。変更や削除は使用中のオーバーレイにも反映されます。',
+      upload: 'ロゴをアップロード',
+      logo: 'カスタムロゴ',
+      from: '開始色',
+      to: '終了色',
+      reset: '色をリセット',
+      failed: 'ロゴを保存できませんでした。画像の形式とサイズを確認してください。',
+    },
     title: 'ストリームオーバーレイ',
     title: 'ストリームオーバーレイ',
     invalidPrinterId: '無効なプリンターID',
     invalidPrinterId: '無効なプリンターID',
     cameraStream: 'カメラストリーム',
     cameraStream: 'カメラストリーム',
@@ -3620,6 +3630,8 @@ export default {
     layer: 'レイヤー',
     layer: 'レイヤー',
     remaining: '残り時間',
     remaining: '残り時間',
     builder: {
     builder: {
+      backgroundTransparency: '背景の透明度',
+      backgroundTransparencyHint: '暗い背景のみを薄くします。テキスト、ロゴ、カメラ映像はそのまま表示されます。',
       artwork: 'デザイン',
       artwork: 'デザイン',
       artworkClassic: 'クラシック',
       artworkClassic: 'クラシック',
       artworkV2: 'バージョン 2',
       artworkV2: 'バージョン 2',

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

@@ -3433,6 +3433,16 @@ export default {
     }
     }
   },
   },
   streamOverlay: {
   streamOverlay: {
+    branding: {
+      title: '브랜딩 설정',
+      hint: '이 설치에서 공유하는 로고입니다. PNG 또는 WebP, 최대 2 MiB 및 400만 픽셀. 교체하거나 삭제하면 사용 중인 오버레이에 영향을 줍니다.',
+      upload: '로고 업로드',
+      logo: '사용자 지정 로고',
+      from: '시작 색상',
+      to: '끝 색상',
+      reset: '색상 초기화',
+      failed: '로고를 저장하지 못했습니다. 이미지 형식과 크기를 확인하세요.',
+    },
     title: '스트림 오버레이',
     title: '스트림 오버레이',
     invalidPrinterId: '유효하지 않은 프린터 ID',
     invalidPrinterId: '유효하지 않은 프린터 ID',
     cameraStream: '카메라 스트림',
     cameraStream: '카메라 스트림',
@@ -3443,6 +3453,8 @@ export default {
     layer: '레이어',
     layer: '레이어',
     remaining: '남은 시간',
     remaining: '남은 시간',
     builder: {
     builder: {
+      backgroundTransparency: '배경 투명도',
+      backgroundTransparencyHint: '어두운 배경만 흐리게 합니다. 텍스트, 로고와 카메라 영상은 그대로 표시됩니다.',
       artwork: '디자인',
       artwork: '디자인',
       artworkClassic: '클래식',
       artworkClassic: '클래식',
       artworkV2: '버전 2',
       artworkV2: '버전 2',

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

@@ -3640,6 +3640,16 @@ export default {
 
 
   // Stream overlay
   // Stream overlay
   streamOverlay: {
   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',
     title: 'Streamoverlay',
     invalidPrinterId: 'Ongeldige printer-ID',
     invalidPrinterId: 'Ongeldige printer-ID',
     cameraStream: 'Camerastream',
     cameraStream: 'Camerastream',
@@ -3650,6 +3660,8 @@ export default {
     layer: 'Laag',
     layer: 'Laag',
     remaining: 'Resterend',
     remaining: 'Resterend',
     builder: {
     builder: {
+      backgroundTransparency: 'Achtergrondtransparantie',
+      backgroundTransparencyHint: 'Vervaag alleen de donkere achtergronden. Tekst, logo’s en camera blijven zichtbaar.',
       artwork: 'Vormgeving',
       artwork: 'Vormgeving',
       artworkClassic: 'Klassiek',
       artworkClassic: 'Klassiek',
       artworkV2: 'Versie 2',
       artworkV2: 'Versie 2',

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

@@ -3597,6 +3597,16 @@ export default {
 
 
   // Stream overlay
   // Stream overlay
   streamOverlay: {
   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',
     title: 'Sobreposição de stream',
     invalidPrinterId: 'ID da impressora inválido',
     invalidPrinterId: 'ID da impressora inválido',
     cameraStream: 'Transmissão da câmera',
     cameraStream: 'Transmissão da câmera',
@@ -3607,6 +3617,8 @@ export default {
     layer: 'Camada',
     layer: 'Camada',
     remaining: 'Tempo restante',
     remaining: 'Tempo restante',
     builder: {
     builder: {
+      backgroundTransparency: 'Transparência do fundo',
+      backgroundTransparencyHint: 'Atenua apenas os fundos escuros. Texto, logotipos e câmera continuam visíveis.',
       artwork: 'Visual',
       artwork: 'Visual',
       artworkClassic: 'Clássico',
       artworkClassic: 'Clássico',
       artworkV2: 'Versão 2',
       artworkV2: 'Versão 2',

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

@@ -3425,6 +3425,16 @@ export default {
     },
     },
   },
   },
   streamOverlay: {
   streamOverlay: {
+    branding: {
+      title: "Оформление",
+      hint: "Общий логотип для этой установки. PNG или WebP, до 2 МиБ и 4 миллионов пикселей. Замена или удаление влияет на использующие его оверлеи.",
+      upload: "Загрузить логотип",
+      logo: "Свой логотип",
+      from: "Начальный цвет",
+      to: "Конечный цвет",
+      reset: "Сбросить цвета",
+      failed: "Не удалось сохранить логотип. Проверьте формат и размер изображения.",
+    },
     title: "Оформление трансляции",
     title: "Оформление трансляции",
     invalidPrinterId: "Неверный идентификатор принтера",
     invalidPrinterId: "Неверный идентификатор принтера",
     cameraStream: "Видеопоток камеры",
     cameraStream: "Видеопоток камеры",
@@ -3435,6 +3445,8 @@ export default {
     layer: 'Слой',
     layer: 'Слой',
     remaining: 'Осталось',
     remaining: 'Осталось',
     builder: {
     builder: {
+      backgroundTransparency: "Прозрачность фона",
+      backgroundTransparencyHint: "Изменяет только тёмный фон. Текст, логотипы и изображение камеры остаются видимыми.",
       artwork: 'Оформление',
       artwork: 'Оформление',
       artworkClassic: 'Классическое',
       artworkClassic: 'Классическое',
       artworkV2: 'Версия 2',
       artworkV2: 'Версия 2',

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

@@ -3639,6 +3639,16 @@ errors: {
 
 
   // Stream overlay
   // Stream overlay
   streamOverlay: {
   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',
     title: 'Strömöverlägg',
     invalidPrinterId: 'Ogiltigt skrivar-ID',
     invalidPrinterId: 'Ogiltigt skrivar-ID',
     cameraStream: 'Kameraström',
     cameraStream: 'Kameraström',
@@ -3649,6 +3659,8 @@ errors: {
     layer: 'Lager',
     layer: 'Lager',
     remaining: 'Återstående',
     remaining: 'Återstående',
     builder: {
     builder: {
+      backgroundTransparency: 'Bakgrundens genomskinlighet',
+      backgroundTransparencyHint: 'Tona bara ned de mörka bakgrunderna. Text, logotyper och kamerabild förblir synliga.',
       artwork: 'Utseende',
       artwork: 'Utseende',
       artworkClassic: 'Klassisk',
       artworkClassic: 'Klassisk',
       artworkV2: 'Version 2',
       artworkV2: 'Version 2',

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

@@ -3612,6 +3612,16 @@ export default {
 
 
   // Akış bindirmesi
   // Akış bindirmesi
   streamOverlay: {
   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',
     title: 'Akış Bindirmesi',
     invalidPrinterId: 'Geçersiz yazıcı ID\'si',
     invalidPrinterId: 'Geçersiz yazıcı ID\'si',
     cameraStream: 'Kamera akışı',
     cameraStream: 'Kamera akışı',
@@ -3622,6 +3632,8 @@ export default {
     layer: 'Katman',
     layer: 'Katman',
     remaining: 'Kalan süre',
     remaining: 'Kalan süre',
     builder: {
     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',
       artwork: 'Tasarım',
       artworkClassic: 'Klasik',
       artworkClassic: 'Klasik',
       artworkV2: 'Sürüm 2',
       artworkV2: 'Sürüm 2',

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

@@ -3637,6 +3637,16 @@ export default {
 
 
   // Stream overlay
   // Stream overlay
   streamOverlay: {
   streamOverlay: {
+    branding: {
+      title: "Оформлення",
+      hint: "Спільний логотип для цієї інсталяції. PNG або WebP, до 2 МіБ і 4 мільйонів пікселів. Заміна або видалення впливає на оверлеї, які його використовують.",
+      upload: "Завантажити логотип",
+      logo: "Власний логотип",
+      from: "Початковий колір",
+      to: "Кінцевий колір",
+      reset: "Скинути кольори",
+      failed: "Не вдалося зберегти логотип. Перевірте формат і розмір зображення.",
+    },
     title: "Накладання потоку",
     title: "Накладання потоку",
     invalidPrinterId: "Недійсний ідентифікатор принтера",
     invalidPrinterId: "Недійсний ідентифікатор принтера",
     cameraStream: "Потік камери",
     cameraStream: "Потік камери",
@@ -3647,6 +3657,8 @@ export default {
     layer: 'Шар',
     layer: 'Шар',
     remaining: 'Залишилось',
     remaining: 'Залишилось',
     builder: {
     builder: {
+      backgroundTransparency: "Прозорість тла",
+      backgroundTransparencyHint: "Змінює лише темне тло. Текст, логотипи та зображення камери залишаються видимими.",
       artwork: 'Оформлення',
       artwork: 'Оформлення',
       artworkClassic: 'Класичне',
       artworkClassic: 'Класичне',
       artworkV2: 'Версія 2',
       artworkV2: 'Версія 2',

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

@@ -3597,6 +3597,16 @@ export default {
 
 
   // Stream overlay
   // Stream overlay
   streamOverlay: {
   streamOverlay: {
+    branding: {
+      title: '品牌设置',
+      hint: '此安装共享一个徽标。支持 PNG 或 WebP,最大 2 MiB 和 400 万像素。替换或删除会影响使用该徽标的叠加层。',
+      upload: '上传徽标',
+      logo: '自定义徽标',
+      from: '起始颜色',
+      to: '结束颜色',
+      reset: '重置颜色',
+      failed: '无法保存徽标。请检查图片格式和大小。',
+    },
     title: '流叠加层',
     title: '流叠加层',
     invalidPrinterId: '无效的打印机 ID',
     invalidPrinterId: '无效的打印机 ID',
     cameraStream: '摄像头流',
     cameraStream: '摄像头流',
@@ -3607,6 +3617,8 @@ export default {
     layer: '层数',
     layer: '层数',
     remaining: '剩余时间',
     remaining: '剩余时间',
     builder: {
     builder: {
+      backgroundTransparency: '背景透明度',
+      backgroundTransparencyHint: '仅淡化深色背景。文字、徽标和摄像头画面保持可见。',
       artwork: '外观',
       artwork: '外观',
       artworkClassic: '经典',
       artworkClassic: '经典',
       artworkV2: '第 2 版',
       artworkV2: '第 2 版',

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

@@ -3597,6 +3597,16 @@ export default {
 
 
   // Stream overlay
   // Stream overlay
   streamOverlay: {
   streamOverlay: {
+    branding: {
+      title: '品牌設定',
+      hint: '此安裝共用一個標誌。支援 PNG 或 WebP,最大 2 MiB 和 400 萬像素。替換或刪除會影響使用該標誌的覆蓋層。',
+      upload: '上傳標誌',
+      logo: '自訂標誌',
+      from: '起始顏色',
+      to: '結束顏色',
+      reset: '重設顏色',
+      failed: '無法儲存標誌。請檢查圖片格式和大小。',
+    },
     title: '流疊加層',
     title: '流疊加層',
     invalidPrinterId: '無效的印表機 ID',
     invalidPrinterId: '無效的印表機 ID',
     cameraStream: '攝影機流',
     cameraStream: '攝影機流',
@@ -3607,6 +3617,8 @@ export default {
     layer: '層數',
     layer: '層數',
     remaining: '剩餘時間',
     remaining: '剩餘時間',
     builder: {
     builder: {
+      backgroundTransparency: '背景透明度',
+      backgroundTransparencyHint: '僅淡化深色背景。文字、標誌和攝影機畫面保持可見。',
       artwork: '外觀',
       artwork: '外觀',
       artworkClassic: '經典',
       artworkClassic: '經典',
       artworkV2: '第 2 版',
       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 { useQuery, useQueryClient } from '@tanstack/react-query';
 import { useTranslation } from 'react-i18next';
 import { useTranslation } from 'react-i18next';
 import { Layers, Clock, Timer, Printer, Flame, Square, Box } from 'lucide-react';
 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 { UpdatedStreamOverlay } from '../components/UpdatedStreamOverlay';
 import { api, ApiError, withStreamToken } from '../api/client';
 import { api, ApiError, withStreamToken } from '../api/client';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
 import { formatDuration, formatETA, type TimeFormat } from '../utils/date';
@@ -16,6 +18,7 @@ type OverlaySize = 'small' | 'medium' | 'large';
 interface OverlayConfig {
 interface OverlayConfig {
   size: OverlaySize;
   size: OverlaySize;
   updatedArtwork: boolean;
   updatedArtwork: boolean;
+  backgroundTransparency: number;
   fps: number;
   fps: number;
   showCamera: boolean;
   showCamera: boolean;
   showProgress: boolean;
   showProgress: boolean;
@@ -54,7 +57,13 @@ function parseConfig(params: URLSearchParams): OverlayConfig {
   const cameraParam = params.get('camera');
   const cameraParam = params.get('camera');
   const showCamera = cameraParam !== 'false' && cameraParam !== '0';
   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 {
   return {
+    backgroundTransparency,
     size: (params.get('size') as OverlaySize) || 'medium',
     size: (params.get('size') as OverlaySize) || 'medium',
     fps,
     fps,
     updatedArtwork: params.get('artwork') === '2',
     updatedArtwork: params.get('artwork') === '2',
@@ -178,6 +187,16 @@ export function StreamOverlayPage() {
   // (status + camera stream) is authenticated by that token instead of a JWT.
   // (status + camera stream) is authenticated by that token instead of a JWT.
   const token = searchParams.get('token');
   const token = searchParams.get('token');
   const kiosk = token != null && 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
   // 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.
   // 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 remainingTime = status.remaining_time;
     const hasRemaining = active && config.showEta && remainingTime != null && remainingTime > 0;
     const hasRemaining = active && config.showEta && remainingTime != null && remainingTime > 0;
     return <UpdatedStreamOverlay
     return <UpdatedStreamOverlay
+      backgroundTransparency={config.backgroundTransparency}
+      customLogo={customLogo}
+      progressBackground={progressBackground}
       size={config.size}
       size={config.size}
       camera={config.showCamera ? { url: streamUrl, rotation: printer?.camera_rotation ?? 0, onError: handleStreamError } : null}
       camera={config.showCamera ? { url: streamUrl, rotation: printer?.camera_rotation ?? 0, onError: handleStreamError } : null}
       name={config.showPrinter ? printer?.name ?? null : 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 */}
       {/* 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">
       <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 && (
           {config.showProgress && isPrinting && (
             <div className="mb-3">
             <div className="mb-3">
               <div className={`flex items-center justify-between mb-1 ${sizes.text}`}>
               <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>
               <div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
               <div className={`w-full bg-white/20 rounded-full ${sizes.progressHeight}`}>
                 <div
                 <div
                   className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
                   className={`bg-bambu-green ${sizes.progressHeight} rounded-full transition-all duration-500`}
-                  style={{ width: `${progress}%` }}
+                  style={{ width: `${progress}%`, background: progressBackground }}
                 />
                 />
               </div>
               </div>
             </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',
+  };
+}