Add stream overlay logos and progress colours (#3208)

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