diff --git a/.gitignore b/.gitignore index 0987631..6ef46cf 100644 --- a/.gitignore +++ b/.gitignore @@ -79,6 +79,7 @@ machine.md # Internal dev specs (not for public repo) docs/superpowers/ +.worktrees/ # MkDocs build output site/ diff --git a/apps/api/main.py b/apps/api/main.py index f342ca9..fe752f2 100644 --- a/apps/api/main.py +++ b/apps/api/main.py @@ -9,7 +9,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from apps.api.core.config import get_settings -from apps.api.routes import agent, avatars, events, exports, health, memory, models, personas, runtime_config, sessions, tts_preview, video_clone, video_creation, voices +from apps.api.routes import agent, avatars, events, exports, health, memory, models, personas, runtime_config, scene_assets, sessions, tts_preview, video_clone, video_creation, voices from opentalking.voice.store import init_voice_store @@ -43,6 +43,7 @@ def create_app() -> FastAPI: app.include_router(personas.router) app.include_router(events.router) app.include_router(exports.router) + app.include_router(scene_assets.router) app.include_router(runtime_config.router) app.include_router(tts_preview.router) app.include_router(video_clone.router) diff --git a/apps/api/routes/avatars.py b/apps/api/routes/avatars.py index 03cc2bb..efa2075 100644 --- a/apps/api/routes/avatars.py +++ b/apps/api/routes/avatars.py @@ -60,6 +60,15 @@ def _is_hidden_avatar(manifest_path: Path) -> bool: return bool((raw.get("metadata") or {}).get("hidden")) +def _avatar_matting_status(manifest_path: Path) -> str: + try: + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return "unknown" + value = str((raw.get("metadata") or {}).get("matting_status") or "").strip() + return value if value in {"unknown", "opaque", "transparent_ready"} else "unknown" + + def _avatar_preview_video_path(avatar_dir: Path) -> Path | None: manifest_path = avatar_dir / "manifest.json" try: @@ -112,6 +121,7 @@ def _summary_from_dir(path: Path) -> AvatarSummary: height=m.height, is_custom=_is_custom_avatar(path / "manifest.json"), has_preview_video=_avatar_preview_video_path(path) is not None, + matting_status=_avatar_matting_status(path / "manifest.json"), ) @@ -144,7 +154,7 @@ async def _read_upload_image(upload: UploadFile) -> Image.Image: image.load() except Exception as exc: # noqa: BLE001 raise HTTPException(status_code=400, detail="invalid image") from exc - return image.convert("RGB") + return image.convert("RGBA") if "A" in image.getbands() else image.convert("RGB") async def _read_upload_video(upload: UploadFile) -> tuple[Image.Image, bytes, str]: @@ -209,6 +219,7 @@ def _write_custom_avatar_manifest( metadata["idle_mode"] = "static" metadata["reference_mode"] = "image" metadata["source_image"] = "source/source.png" + metadata["matting_status"] = "opaque" raw["metadata"] = metadata target_manifest_path.write_text(json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") @@ -235,6 +246,24 @@ def _resize_uploaded_avatar_image(image: Image.Image, *, max_width: int, max_hei return fitted +def _avatar_image_has_alpha(image: Image.Image) -> bool: + if "A" not in image.getbands(): + return False + alpha = image.getchannel("A") + low, high = alpha.getextrema() + if not isinstance(low, int | float) or not isinstance(high, int | float): + return False + return low < 255 or high < 255 + + +def _update_manifest_matting_status(manifest_path: Path, image: Image.Image) -> None: + raw = json.loads(manifest_path.read_text(encoding="utf-8")) + metadata = dict(raw.get("metadata") or {}) + metadata["matting_status"] = "transparent_ready" if _avatar_image_has_alpha(image) else "opaque" + raw["metadata"] = metadata + manifest_path.write_text(json.dumps(raw, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + + def _update_manifest_dimensions(manifest_path: Path, image: Image.Image) -> None: raw = json.loads(manifest_path.read_text(encoding="utf-8")) raw["width"] = int(image.width) @@ -916,6 +945,7 @@ async def create_custom_avatar( max_w, max_h = _custom_avatar_max_size() fitted_image = _resize_uploaded_avatar_image(image_rgb, max_width=max_w, max_height=max_h) _update_manifest_dimensions(target_dir / "manifest.json", fitted_image) + _update_manifest_matting_status(target_dir / "manifest.json", fitted_image) fitted_image.save(target_dir / "preview.png", format="PNG") fitted_image.save(target_dir / "reference.png", format="PNG") source_dir = target_dir / "source" diff --git a/apps/api/routes/scene_assets.py b/apps/api/routes/scene_assets.py new file mode 100644 index 0000000..f440348 --- /dev/null +++ b/apps/api/routes/scene_assets.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile +from fastapi.responses import FileResponse + +from apps.api.schemas.scene_assets import ( + CreateSceneCompositionRequest, + UpdateSceneCompositionRequest, +) +from opentalking.scene_assets import SceneAssetStore + + +router = APIRouter(prefix="/scene-assets", tags=["scene-assets"]) + + +def _store(request: Request) -> SceneAssetStore: + settings = request.app.state.settings + root = Path(getattr(settings, "scene_assets_dir", "./data/scene-assets")) + return SceneAssetStore(root) + + +@router.get("/backgrounds", response_model=None) +async def list_backgrounds(request: Request) -> dict[str, object]: + return {"items": _store(request).list_backgrounds()} + + +@router.post("/backgrounds", response_model=None) +async def upload_background( + request: Request, + file: UploadFile = File(...), + name: str = Form(""), +) -> dict[str, object]: + max_bytes = int(getattr(request.app.state.settings, "scene_asset_max_bytes", 200 * 1024 * 1024)) + content = await file.read(max_bytes + 1) + if not content: + raise HTTPException(status_code=400, detail="empty background asset") + if len(content) > max_bytes: + raise HTTPException(status_code=413, detail="background asset exceeds size limit") + try: + return _store(request).create_background( + content=content, + filename=file.filename or "background", + mime_type=file.content_type or "application/octet-stream", + name=name or Path(file.filename or "background").stem, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/backgrounds/{background_id}/file", response_model=None) +async def download_background(background_id: str, request: Request) -> FileResponse: + store = _store(request) + path = store.background_file_path(background_id) + if path is None: + raise HTTPException(status_code=404, detail="background not found") + item = next((entry for entry in store.list_backgrounds() if entry.get("id") == background_id), None) + return FileResponse(path, media_type=str(item.get("mime_type") if item else "application/octet-stream")) + + +@router.delete("/backgrounds/{background_id}", response_model=None) +async def delete_background(background_id: str, request: Request) -> dict[str, object]: + deleted = _store(request).delete_background(background_id) + if not deleted: + raise HTTPException(status_code=404, detail="background not found") + return {"id": background_id, "deleted": True} + + +@router.get("/compositions", response_model=None) +async def list_compositions(request: Request) -> dict[str, object]: + return {"items": _store(request).list_compositions()} + + +@router.post("/compositions", response_model=None) +async def create_composition(payload: CreateSceneCompositionRequest, request: Request) -> dict[str, object]: + try: + return _store(request).create_composition(payload.model_dump()) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.patch("/compositions/{composition_id}", response_model=None) +async def update_composition( + composition_id: str, + payload: UpdateSceneCompositionRequest, + request: Request, +) -> dict[str, object]: + data = payload.model_dump(exclude_unset=True) + try: + updated = _store(request).update_composition(composition_id, data) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + if updated is None: + raise HTTPException(status_code=404, detail="composition not found") + return updated + + +@router.delete("/compositions/{composition_id}", response_model=None) +async def delete_composition(composition_id: str, request: Request) -> dict[str, object]: + deleted = _store(request).delete_composition(composition_id) + if not deleted: + raise HTTPException(status_code=404, detail="composition not found") + return {"id": composition_id, "deleted": True} diff --git a/apps/api/schemas/avatar.py b/apps/api/schemas/avatar.py index 5514501..a8e6025 100644 --- a/apps/api/schemas/avatar.py +++ b/apps/api/schemas/avatar.py @@ -14,3 +14,4 @@ class AvatarSummary(BaseModel): # True for avatars created via POST /avatars/custom; only these are deletable. is_custom: bool = False has_preview_video: bool = False + matting_status: str = "unknown" diff --git a/apps/api/schemas/scene_assets.py b/apps/api/schemas/scene_assets.py new file mode 100644 index 0000000..7a7e5e8 --- /dev/null +++ b/apps/api/schemas/scene_assets.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class SceneBackgroundAsset(BaseModel): + id: str + name: str + kind: str + mime_type: str + filename: str + size_bytes: int + url: str + created_at: str + + +class SceneComposition(BaseModel): + id: str + name: str + avatar_id: str + background_id: str | None = None + background_color: str = "#0f172a" + avatar_fit: str = "contain" + avatar_scale: float = Field(default=1.0, ge=0.5, le=2.0) + avatar_anchor: str = "center" + matting_required: bool = False + subtitle_style: str = "lower-third" + created_at: str + updated_at: str + + +class CreateSceneCompositionRequest(BaseModel): + name: str + avatar_id: str + background_id: str | None = None + background_color: str = "#0f172a" + avatar_fit: str = "contain" + avatar_scale: float = Field(default=1.0, ge=0.5, le=2.0) + avatar_anchor: str = "center" + matting_required: bool = False + subtitle_style: str = "lower-third" + + +class UpdateSceneCompositionRequest(BaseModel): + name: str | None = None + avatar_id: str | None = None + background_id: str | None = None + background_color: str | None = None + avatar_fit: str | None = None + avatar_scale: float | None = Field(default=None, ge=0.5, le=2.0) + avatar_anchor: str | None = None + matting_required: bool | None = None + subtitle_style: str | None = None diff --git a/apps/api/tests/test_custom_avatars.py b/apps/api/tests/test_custom_avatars.py index 0579ad9..4d5c411 100644 --- a/apps/api/tests/test_custom_avatars.py +++ b/apps/api/tests/test_custom_avatars.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import hashlib from io import BytesIO from pathlib import Path from types import SimpleNamespace @@ -12,6 +13,8 @@ from PIL import Image from apps.cli import prepare_cache from apps.api.routes import avatars, sessions +REPO_ROOT = Path(__file__).resolve().parents[3] + def _png_bytes(size: tuple[int, int] = (8, 8)) -> bytes: out = BytesIO() @@ -19,6 +22,14 @@ def _png_bytes(size: tuple[int, int] = (8, 8)) -> bytes: return out.getvalue() +def _transparent_png_bytes(size: tuple[int, int] = (8, 8)) -> bytes: + image = Image.new("RGBA", size, (10, 180, 210, 255)) + image.putpixel((0, 0), (10, 180, 210, 0)) + out = BytesIO() + image.save(out, format="PNG") + return out.getvalue() + + def _path_has_suffix(path: str, *suffix: str) -> bool: return Path(path).parts[-len(suffix):] == suffix @@ -74,6 +85,106 @@ def test_create_custom_avatar_adds_listed_asset_with_preview(tmp_path): assert preview.headers["content-type"] == "image/png" +def test_avatar_summary_includes_matting_status(tmp_path: Path) -> None: + root = tmp_path + avatar_dir = root / "anchor" + avatar_dir.mkdir() + (avatar_dir / "manifest.json").write_text( + json.dumps( + { + "id": "anchor", + "name": "Anchor", + "model_type": "flashtalk", + "fps": 25, + "sample_rate": 16000, + "width": 1, + "height": 1, + "version": "1.0", + "metadata": {"matting_status": "transparent_ready"}, + } + ), + encoding="utf-8", + ) + (avatar_dir / "preview.png").write_bytes(_png_bytes()) + (avatar_dir / "reference.png").write_bytes(_png_bytes()) + + app = FastAPI() + app.state.settings = SimpleNamespace(avatars_dir=str(root)) + app.include_router(avatars.router) + response = TestClient(app).get("/avatars") + + assert response.status_code == 200 + assert response.json()[0]["matting_status"] == "transparent_ready" + + +def test_builtin_female_host_transparent_avatar_asset_is_ready() -> None: + avatar_dir = REPO_ROOT / "examples" / "avatars" / "female-host-transparent" + manifest = json.loads((avatar_dir / "manifest.json").read_text(encoding="utf-8")) + reference = avatar_dir / "reference.png" + preview = avatar_dir / "preview.png" + source = avatar_dir / "source" / "source.png" + + assert manifest["id"] == "female-host-transparent" + assert manifest["name"] == "女主持(透明背景)" + assert manifest["metadata"]["matting_status"] == "transparent_ready" + assert manifest["metadata"]["source_image"] == "source/source.png" + assert manifest["metadata"]["source_image_path"] == "reference.png" + assert reference.is_file() + assert preview.is_file() + assert source.is_file() + + image = Image.open(reference) + assert image.mode == "RGBA" + assert image.size == (manifest["width"], manifest["height"]) + assert image.getchannel("A").getextrema() == (0, 255) + assert hashlib.sha256(reference.read_bytes()).hexdigest() == manifest["metadata"]["source_image_hash"] + + +def test_create_custom_avatar_preserves_uploaded_png_alpha(tmp_path, monkeypatch): + base = tmp_path / "base-avatar" + base.mkdir() + (base / "preview.png").write_bytes(_png_bytes()) + (base / "reference.png").write_bytes(_png_bytes()) + (base / "manifest.json").write_text( + json.dumps( + { + "id": "base-avatar", + "name": "Base Avatar", + "model_type": "mock", + "fps": 25, + "sample_rate": 16000, + "width": 8, + "height": 8, + "version": "1.0", + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(avatars.mouth_metadata, "detect_mouth_landmarks", lambda frame: None) + + app = FastAPI() + app.state.settings = SimpleNamespace(avatars_dir=str(tmp_path)) + app.include_router(avatars.router) + client = TestClient(app) + + response = client.post( + "/avatars/custom", + data={"base_avatar_id": "base-avatar", "name": "透明形象"}, + files={"image": ("avatar.png", _transparent_png_bytes(), "image/png")}, + ) + + assert response.status_code == 200 + created = response.json() + assert created["matting_status"] == "transparent_ready" + custom_dir = tmp_path / created["id"] + manifest = json.loads((custom_dir / "manifest.json").read_text(encoding="utf-8")) + assert manifest["metadata"]["matting_status"] == "transparent_ready" + for rel in ("reference.png", "preview.png", "source/source.png"): + image = Image.open(custom_dir / rel) + assert image.mode == "RGBA" + assert image.getchannel("A").getextrema()[0] == 0 + + def test_quicktalk_model_root_falls_back_to_omnirt_model_root(tmp_path, monkeypatch): monkeypatch.delenv("OPENTALKING_QUICKTALK_ASSET_ROOT", raising=False) monkeypatch.delenv("OPENTALKING_QUICKTALK_MODEL_ROOT", raising=False) diff --git a/apps/api/tests/test_scene_assets.py b/apps/api/tests/test_scene_assets.py new file mode 100644 index 0000000..7e42347 --- /dev/null +++ b/apps/api/tests/test_scene_assets.py @@ -0,0 +1,290 @@ +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import apps.unified.main as unified_main +from apps.api.routes import scene_assets +from opentalking.scene_assets import SceneAssetStore + + +PNG_BYTES = ( + b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89" + b"\x00\x00\x00\rIDATx\x9cc\xf8\xff\xff?\x00\x05\xfe\x02\xfeA\xe2!\xbc" + b"\x00\x00\x00\x00IEND\xaeB`\x82" +) + + +def test_scene_asset_store_creates_lists_and_deletes_background(tmp_path: Path) -> None: + store = SceneAssetStore(tmp_path) + + created = store.create_background( + content=PNG_BYTES, + filename="studio.png", + mime_type="image/png", + name="Studio", + ) + + assert created["id"].startswith("bg-") + assert created["name"] == "Studio" + assert created["kind"] == "image" + assert created["mime_type"] == "image/png" + assert created["url"] == f"/scene-assets/backgrounds/{created['id']}/file" + assert store.background_file_path(str(created["id"])).is_file() + assert store.list_backgrounds()[0]["id"] == created["id"] + assert store.delete_background(str(created["id"])) is True + assert store.list_backgrounds() == [] + + +def test_scene_asset_store_rejects_unsupported_background_type(tmp_path: Path) -> None: + store = SceneAssetStore(tmp_path) + + with pytest.raises(ValueError, match="unsupported background media type"): + store.create_background( + content=b"not media", + filename="notes.txt", + mime_type="text/plain", + name="Notes", + ) + + +def test_scene_asset_store_rejects_spoofed_background_content(tmp_path: Path) -> None: + store = SceneAssetStore(tmp_path) + + with pytest.raises(ValueError, match="unsupported background media type"): + store.create_background( + content=b"plain text pretending to be an image", + filename="studio.png", + mime_type="image/png", + name="Spoof", + ) + + +def test_scene_asset_store_rejects_zero_avatar_scale(tmp_path: Path) -> None: + store = SceneAssetStore(tmp_path) + + with pytest.raises(ValueError, match="avatar_scale must be between 0.5 and 2.0"): + store.create_composition( + { + "name": "Zero Scale", + "avatar_id": "anchor", + "avatar_scale": 0, + } + ) + + +def test_scene_asset_store_rejects_unsafe_background_delete_id(tmp_path: Path) -> None: + store = SceneAssetStore(tmp_path / "assets") + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + (outside_dir / "keep.txt").write_text("keep", encoding="utf-8") + + unsafe_id = f"bg-valid/../../{outside_dir.name}" + store.backgrounds_dir.mkdir(parents=True) + store.background_index_path.write_text( + f'[{{"id": "{unsafe_id}", "mime_type": "image/png"}}]\n', + encoding="utf-8", + ) + + assert store.delete_background(unsafe_id) is False + assert (outside_dir / "keep.txt").is_file() + + +def test_scene_asset_store_creates_and_updates_composition(tmp_path: Path) -> None: + store = SceneAssetStore(tmp_path) + background = store.create_background( + content=PNG_BYTES, + filename="studio.png", + mime_type="image/png", + name="Studio", + ) + + created = store.create_composition( + { + "name": "Demo Scene", + "avatar_id": "anchor", + "background_id": background["id"], + "avatar_fit": "contain", + "avatar_scale": 1.0, + "avatar_anchor": "center", + "matting_required": True, + "subtitle_style": "lower-third", + } + ) + + assert created["id"].startswith("scene-") + assert created["background_id"] == background["id"] + assert created["matting_required"] is True + assert store.list_compositions()[0]["id"] == created["id"] + + updated = store.update_composition(str(created["id"]), {"name": "Updated", "avatar_scale": 1.25}) + assert updated is not None + assert updated["name"] == "Updated" + assert updated["avatar_scale"] == 1.25 + assert updated["avatar_id"] == "anchor" + + assert store.delete_composition(str(created["id"])) is True + assert store.list_compositions() == [] + + +def test_scene_asset_store_rejects_unknown_background_id(tmp_path: Path) -> None: + store = SceneAssetStore(tmp_path) + + with pytest.raises(ValueError, match="background_id not found"): + store.create_composition( + { + "name": "Missing Background", + "avatar_id": "anchor", + "background_id": "bg-missing", + } + ) + + background = store.create_background( + content=PNG_BYTES, + filename="studio.png", + mime_type="image/png", + name="Studio", + ) + created = store.create_composition( + { + "name": "Demo Scene", + "avatar_id": "anchor", + "background_id": background["id"], + } + ) + with pytest.raises(ValueError, match="background_id not found"): + store.update_composition(str(created["id"]), {"background_id": "bg-missing"}) + + +def _client(tmp_path: Path) -> TestClient: + app = FastAPI() + app.state.settings = SimpleNamespace( + scene_assets_dir=str(tmp_path / "scene-assets"), + scene_asset_max_bytes=len(PNG_BYTES), + ) + app.include_router(scene_assets.router) + return TestClient(app) + + +def test_scene_asset_api_uploads_lists_downloads_and_deletes_background(tmp_path: Path) -> None: + with _client(tmp_path) as client: + upload = client.post( + "/scene-assets/backgrounds", + data={"name": "Studio"}, + files={"file": ("studio.png", PNG_BYTES, "image/png")}, + ) + assert upload.status_code == 200 + item = upload.json() + assert item["name"] == "Studio" + assert item["url"] == f"/scene-assets/backgrounds/{item['id']}/file" + + listed = client.get("/scene-assets/backgrounds") + assert listed.status_code == 200 + assert listed.json()["items"][0]["id"] == item["id"] + + downloaded = client.get(item["url"]) + assert downloaded.status_code == 200 + assert downloaded.headers["content-type"].startswith("image/png") + + deleted = client.delete(f"/scene-assets/backgrounds/{item['id']}") + assert deleted.status_code == 200 + assert deleted.json()["deleted"] is True + + +def test_scene_asset_api_rejects_oversized_background_upload(tmp_path: Path) -> None: + with _client(tmp_path) as client: + upload = client.post( + "/scene-assets/backgrounds", + data={"name": "Too Big"}, + files={"file": ("studio.png", PNG_BYTES + b"x", "image/png")}, + ) + assert upload.status_code == 413 + assert upload.json()["detail"] == "background asset exceeds size limit" + + +def test_scene_asset_api_rejects_spoofed_content_type(tmp_path: Path) -> None: + with _client(tmp_path) as client: + upload = client.post( + "/scene-assets/backgrounds", + data={"name": "Spoof"}, + files={"file": ("studio.png", b"this is plain text", "image/png")}, + ) + assert upload.status_code == 400 + assert upload.json()["detail"] == "unsupported background media type" + + +def test_scene_asset_api_manages_compositions(tmp_path: Path) -> None: + with _client(tmp_path) as client: + bg = client.post( + "/scene-assets/backgrounds", + data={"name": "Studio"}, + files={"file": ("studio.png", PNG_BYTES, "image/png")}, + ).json() + + created = client.post( + "/scene-assets/compositions", + json={ + "name": "Demo Scene", + "avatar_id": "anchor", + "background_id": bg["id"], + "avatar_fit": "contain", + "avatar_scale": 1.0, + "avatar_anchor": "center", + "matting_required": True, + "subtitle_style": "lower-third", + }, + ) + assert created.status_code == 200 + scene = created.json() + assert scene["background_id"] == bg["id"] + + patched = client.patch( + f"/scene-assets/compositions/{scene['id']}", + json={"name": "Updated Scene", "avatar_scale": 1.2}, + ) + assert patched.status_code == 200 + assert patched.json()["name"] == "Updated Scene" + assert patched.json()["avatar_scale"] == 1.2 + + listed = client.get("/scene-assets/compositions") + assert listed.status_code == 200 + assert listed.json()["items"][0]["id"] == scene["id"] + + deleted = client.delete(f"/scene-assets/compositions/{scene['id']}") + assert deleted.status_code == 200 + assert deleted.json() == {"id": scene["id"], "deleted": True} + + listed_after_delete = client.get("/scene-assets/compositions") + assert listed_after_delete.status_code == 200 + assert listed_after_delete.json()["items"] == [] + + deleted_again = client.delete(f"/scene-assets/compositions/{scene['id']}") + assert deleted_again.status_code == 404 + + +def test_scene_asset_api_rejects_unknown_background_id(tmp_path: Path) -> None: + with _client(tmp_path) as client: + created = client.post( + "/scene-assets/compositions", + json={ + "name": "Missing Background", + "avatar_id": "anchor", + "background_id": "bg-missing", + }, + ) + assert created.status_code == 400 + assert created.json()["detail"] == "background_id not found" + + +def test_unified_app_registers_scene_asset_routes() -> None: + app = unified_main.create_app() + + paths = set(app.openapi()["paths"]) + + assert "/scene-assets/backgrounds" in paths + assert "/scene-assets/compositions" in paths diff --git a/apps/unified/main.py b/apps/unified/main.py index d24ce06..eb6d1e3 100644 --- a/apps/unified/main.py +++ b/apps/unified/main.py @@ -19,7 +19,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from apps.api.core.config import get_settings from apps.api.routes.avatars import _call_adapter_warmup -from apps.api.routes import agent, avatars, events, exports, health, memory, models, personas, runtime_config, sessions, tts_preview, video_clone, video_creation, voices +from apps.api.routes import agent, avatars, events, exports, health, memory, models, personas, runtime_config, scene_assets, sessions, tts_preview, video_clone, video_creation, voices from opentalking.voice.store import init_voice_store from opentalking.core.in_memory_redis import InMemoryRedis from opentalking.pipeline.session.runner import SessionRunner @@ -219,6 +219,7 @@ def create_app() -> FastAPI: app.include_router(personas.router) app.include_router(events.router) app.include_router(exports.router) + app.include_router(scene_assets.router) app.include_router(runtime_config.router) app.include_router(tts_preview.router) app.include_router(video_clone.router) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 18d6a2d..5d6c0aa 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -11,11 +11,12 @@ import { type Wav2LipPostprocessMode, } from "./components/SettingsPanel"; import { RuntimeConfigWorkspace } from "./components/RuntimeConfigWorkspace"; -import { TopBar, type StudioWorkflow } from "./components/TopBar"; +import { SceneStage } from "./components/SceneStage"; +import { TopBar, type ConversationViewMode, type StudioWorkflow } from "./components/TopBar"; import { ToastStack, type ToastMessage, type ToastTone } from "./components/ToastStack"; -import { VideoBackground } from "./components/VideoBackground"; import { AssetLibraryWorkspace, type AssetLibraryTab } from "./components/AssetLibraryWorkspace"; import { VideoCloneWorkspace } from "./components/VideoCloneWorkspace"; +import { playWithMutedFallback } from "./components/VideoBackground"; import { DEFAULT_VIDEO_CREATION_FASTLIVEPORTRAIT_CONFIG, VideoCreationWorkspace, @@ -31,6 +32,8 @@ import { apiUploadFile, buildApiUrl, getMemoryLibraries, + listSceneBackgrounds, + listSceneCompositions, loadRuntimeConfig, uploadExportVideo, type AvatarKnowledgeBasesResponse, @@ -43,6 +46,8 @@ import { type PersonasResponse, type RuntimeConfigApplyInput, type RuntimeConfigResponse, + type SceneBackgroundAsset, + type SceneComposition, type SessionKnowledgeBasesRequest, type SessionKnowledgeBasesResponse, type VoiceCatalogItem, @@ -186,6 +191,9 @@ const ASR_PROVIDER_STORAGE_KEY = "opentalking-asr-provider-v1"; const CLIENT_USER_ID_KEY = "opentalking-client-user-id"; const AGENT_CONFIG_STORAGE_KEY = "opentalking-agent-config-v1"; const SELECTED_PERSONA_STORAGE_KEY = "opentalking-selected-persona-id-v1"; +const SELECTED_SCENE_STORAGE_KEY = "opentalking-selected-scene-id-v2"; +const SELECTED_SCENE_BY_AVATAR_STORAGE_KEY = "opentalking-selected-scene-by-avatar-v1"; +const CONVERSATION_VIEW_MODE_KEY = "opentalking-conversation-view-mode-v1"; const LEGACY_FASTLIVEPORTRAIT_DEFAULT_CONFIG: FasterLivePortraitConfig = { head_motion_multiplier: 1.0, pose_motion_multiplier: 0.35, @@ -347,6 +355,22 @@ function writeStoredAvatarId(avatarId: string, source: "auto" | "explicit" = "ex } } +function readStoredSceneIdsByAvatar(): Record { + try { + const raw = window.localStorage.getItem(SELECTED_SCENE_BY_AVATAR_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {}; + return Object.fromEntries( + Object.entries(parsed).filter((entry): entry is [string, string] => ( + typeof entry[0] === "string" && typeof entry[1] === "string" && entry[0].length > 0 && entry[1].length > 0 + )), + ); + } catch { + return {}; + } +} + function readOrCreateClientUserId(): string { try { const existing = window.localStorage.getItem(CLIENT_USER_ID_KEY)?.trim(); @@ -828,6 +852,7 @@ function realtimeRecordingStartErrorMessage(error: unknown): string { export default function App() { const videoRef = useRef(null); + const audioRef = useRef(null); const pcRef = useRef(null); const remoteStreamRef = useRef(null); const realtimeRecorderRef = useRef(null); @@ -886,6 +911,7 @@ export default function App() { // Connection const [connection, setConnection] = useState("idle"); const [sessionId, setSessionId] = useState(null); + const [remoteStream, setRemoteStream] = useState(null); const [queueInfo, setQueueInfo] = useState(null); const [expiringCountdown, setExpiringCountdown] = useState(null); @@ -972,6 +998,14 @@ export default function App() { const [ftRecordPhase, setFtRecordPhase] = useState<"idle" | "recording" | "stopped">("idle"); const [ftRecordBusy, setFtRecordBusy] = useState(false); const [assetLibraryRefreshKey, setAssetLibraryRefreshKey] = useState(0); + const [conversationViewMode, setConversationViewMode] = useState(() => { + try { + return window.localStorage.getItem(CONVERSATION_VIEW_MODE_KEY) === "immersive" ? "immersive" : "studio"; + } catch { + return "studio"; + } + }); + const [immersiveAvatarAdjust, setImmersiveAvatarAdjust] = useState({ x: 0, y: 0, scale: 1 }); const [voiceCatalog, setVoiceCatalog] = useState([]); const [voiceApplyNotice, setVoiceApplyNotice] = useState(null); const [ttsPreviewText, setTtsPreviewText] = useState(DEFAULT_TTS_PREVIEW_TEXT); @@ -982,6 +1016,9 @@ export default function App() { const [memoryEnabled, setMemoryEnabled] = useState(false); const [memoryLibraryId, setMemoryLibraryId] = useState(null); const [memoryLibraries, setMemoryLibraries] = useState([]); + const [sceneBackgrounds, setSceneBackgrounds] = useState([]); + const [sceneCompositions, setSceneCompositions] = useState([]); + const [selectedSceneIdsByAvatar, setSelectedSceneIdsByAvatar] = useState>(readStoredSceneIdsByAvatar); const avatarKnowledgeBasesSyncReadyRef = useRef(false); const lastPersistedAvatarKnowledgeBasesRef = useRef>(new Map()); const avatarKnowledgeBasesLoadSeqRef = useRef(0); @@ -1041,6 +1078,17 @@ export default function App() { }); const compactSquareStage = usesCompactSquareStage(model); + const selectedScene = useMemo( + () => { + const selectedSceneId = selectedSceneIdsByAvatar[avatarId]; + const matchingScenes = sceneCompositions.filter((scene) => scene.avatar_id === avatarId); + if (selectedSceneId) { + return matchingScenes.find((scene) => scene.id === selectedSceneId) ?? null; + } + return matchingScenes.length === 1 ? matchingScenes[0] : null; + }, + [avatarId, sceneCompositions, selectedSceneIdsByAvatar], + ); const dismissToast = useCallback((id: string) => { setToasts((prev) => prev.filter((toast) => toast.id !== id)); @@ -1177,6 +1225,63 @@ export default function App() { } }, [notify]); + const refreshScenes = useCallback(async () => { + try { + const [backgrounds, scenes] = await Promise.all([ + listSceneBackgrounds(), + listSceneCompositions(), + ]); + setSceneBackgrounds(backgrounds.items); + setSceneCompositions(scenes.items); + setSelectedSceneIdsByAvatar((current) => { + const next = Object.fromEntries( + Object.entries(current).filter(([sceneAvatarId, sceneId]) => ( + scenes.items.some((scene) => scene.id === sceneId && scene.avatar_id === sceneAvatarId) + )), + ); + try { + const legacySceneId = window.localStorage.getItem(SELECTED_SCENE_STORAGE_KEY); + const legacyScene = legacySceneId ? scenes.items.find((scene) => scene.id === legacySceneId) : null; + if (legacyScene && !next[legacyScene.avatar_id]) { + next[legacyScene.avatar_id] = legacyScene.id; + } + window.localStorage.removeItem(SELECTED_SCENE_STORAGE_KEY); + } catch { + /* ignore */ + } + return next; + }); + } catch (error) { + console.warn("load scene assets failed", error); + } + }, []); + + const handleSceneCompositionsChange = useCallback((scenes: SceneComposition[]) => { + setSceneCompositions(scenes); + setSelectedSceneIdsByAvatar((current) => { + return Object.fromEntries( + Object.entries(current).filter(([sceneAvatarId, sceneId]) => ( + scenes.some((scene) => scene.id === sceneId && scene.avatar_id === sceneAvatarId) + )), + ); + }); + }, []); + + const handleSceneSelect = useCallback((scene: SceneComposition) => { + setSelectedSceneIdsByAvatar((current) => ({ + ...current, + [scene.avatar_id]: scene.id, + })); + }, []); + + const handleSceneClear = useCallback((sceneAvatarId: string) => { + setSelectedSceneIdsByAvatar((current) => { + const next = { ...current }; + delete next[sceneAvatarId]; + return next; + }); + }, []); + const refreshAvatarKnowledgeBases = useCallback(async (targetAvatarId: string) => { if (!targetAvatarId) return; const seq = ++avatarKnowledgeBasesLoadSeqRef.current; @@ -1259,6 +1364,46 @@ export default function App() { if (workflow === "realtime") void refreshPersonas(); }, [refreshPersonas, workflow]); + useEffect(() => { + void refreshScenes(); + }, [refreshScenes]); + + useEffect(() => { + try { + if (Object.keys(selectedSceneIdsByAvatar).length) { + window.localStorage.setItem(SELECTED_SCENE_BY_AVATAR_STORAGE_KEY, JSON.stringify(selectedSceneIdsByAvatar)); + } else { + window.localStorage.removeItem(SELECTED_SCENE_BY_AVATAR_STORAGE_KEY); + } + } catch { + /* ignore */ + } + }, [selectedSceneIdsByAvatar]); + + useEffect(() => { + try { + window.localStorage.setItem(CONVERSATION_VIEW_MODE_KEY, conversationViewMode); + } catch { + /* ignore */ + } + }, [conversationViewMode]); + + useEffect(() => { + const startupVisible = connection === "idle" || connection === "error" || connection === "connecting" || connection === "queued"; + if (startupVisible && conversationViewMode === "immersive") setConversationViewMode("studio"); + }, [connection, conversationViewMode]); + + useEffect(() => { + if (workflow !== "realtime" || conversationViewMode !== "immersive") return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape") return; + event.preventDefault(); + setConversationViewMode("studio"); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [conversationViewMode, workflow]); + useEffect(() => { if (selectedPersonaId) return; void refreshAvatarKnowledgeBases(avatarId); @@ -1560,6 +1705,37 @@ export default function App() { sessionIdRef.current = sessionId; }, [sessionId]); + useEffect(() => { + const video = videoRef.current; + if (!video) return; + if (video.srcObject !== remoteStream) { + video.srcObject = remoteStream; + } + if (remoteStream) { + video.muted = false; + video.volume = 1; + playWithMutedFallback(video); + } else { + video.muted = true; + } + }, [conversationViewMode, remoteStream, workflow]); + + useEffect(() => { + const audio = audioRef.current; + if (!audio) return; + if (!remoteStream) { + audio.srcObject = null; + return; + } + audio.srcObject = remoteStream; + audio.muted = false; + audio.volume = 1; + void audio.play().catch(() => {}); + return () => { + audio.srcObject = null; + }; + }, [remoteStream]); + useEffect(() => { return () => { if (ttsPreviewUrlRef.current) { @@ -1656,6 +1832,7 @@ export default function App() { for (const track of remoteStreamRef.current.getTracks()) track.stop(); remoteStreamRef.current = null; } + setRemoteStream(null); }, []); const releaseSession = useCallback(async (sid: string, keepalive = false) => { @@ -2047,10 +2224,12 @@ export default function App() { const playback = await startPlayback(created.session_id, videoRef.current!, { onRemoteStream: (remoteStream) => { remoteStreamRef.current = remoteStream; + setRemoteStream(remoteStream); }, }); pcRef.current = playback.pc; remoteStreamRef.current = playback.remoteStream; + setRemoteStream(playback.remoteStream); setActiveAsrProvider(lockedAsrProvider); videoRef.current!.muted = false; setConnection("live"); @@ -2605,9 +2784,14 @@ export default function App() { }, [closePeerConnection, releaseSession]); const currentAvatar = avatars.find((a) => a.id === avatarId) ?? null; + const selectedAvatarMaskUrl = selectedScene && currentAvatar?.matting_status === "transparent_ready" + ? buildApiUrl(`/avatars/${encodeURIComponent(currentAvatar.id)}/preview`) + : null; const sessionConfigLocked = connection === "connecting" || connection === "queued" || connection === "live" || connection === "expiring"; const effectiveAsrProvider = activeAsrProvider || normalizeAsrProvider(asrProvider, "dashscope"); const showStart = connection === "idle" || connection === "error" || connection === "connecting" || connection === "queued"; + const effectiveConversationViewMode = showStart ? "studio" : conversationViewMode; + const immersiveActive = workflow === "realtime" && effectiveConversationViewMode === "immersive"; const chatMaxVisible = readChatMaxVisible(); const selectedModelLabel = MODEL_LABELS_FOR_STAGE[model] ?? model; const wav2lipPostprocessModeLocked = sessionId !== null && connection !== "idle" && connection !== "error"; @@ -2640,9 +2824,12 @@ export default function App() { }; return (
+
) : workflow === "videoCreation" ? ( @@ -2761,8 +2955,14 @@ export default function App() { /> ) : ( -
-
+
+
-
-
-
-
-
-
-
- -
-
- -
-
- - - {connection === "live" || connection === "expiring" ? "已连接" : "待启动"} - - - WebRTC 舞台 - - - {MODEL_LABELS_FOR_STAGE[model] ?? model} - - - {currentAvatar?.name ?? currentAvatar?.id ?? "未选形象"} - -
- -
- - {currentSubtitle && !showStart ? ( -
- {currentSubtitle} -
- ) : null} +
+
+
+ + {immersiveActive ? ( + <> +
+ +
+
+
+ 微调 +
+
+
+

画面微调

+ +
+ + + +
+
+ + ) : ( +
+
+ + + {connection === "live" || connection === "expiring" ? "已连接" : "待启动"} + + + WebRTC 舞台 + + + {MODEL_LABELS_FOR_STAGE[model] ?? model} + + + {currentAvatar?.name ?? currentAvatar?.id ?? "未选形象"} + +
+ +
+ )} +
{showStart ? (
@@ -2909,7 +3195,18 @@ export default function App() {
{connection === "live" || connection === "expiring" ? ( -
+
+ {immersiveActive ? ( +
+
+
+ ) : null}
diff --git a/apps/web/src/components/AvatarSelectionStage.tsx b/apps/web/src/components/AvatarSelectionStage.tsx index 5848542..6bdcd1b 100644 --- a/apps/web/src/components/AvatarSelectionStage.tsx +++ b/apps/web/src/components/AvatarSelectionStage.tsx @@ -271,7 +271,7 @@ export function AvatarSelectionStage({ />
- + {avatar.is_custom ? "自定义形象" : "数字人形象"}
diff --git a/apps/web/src/components/ImmersiveConversation.tsx b/apps/web/src/components/ImmersiveConversation.tsx new file mode 100644 index 0000000..34d9ec3 --- /dev/null +++ b/apps/web/src/components/ImmersiveConversation.tsx @@ -0,0 +1,114 @@ +import { useEffect, type RefObject } from "react"; +import type { TtsProviderExtended } from "../constants/ttsBailian"; +import type { SceneBackgroundAsset, SceneComposition } from "../lib/api"; +import type { ConnectionStatus } from "../types"; +import { ChatInput } from "./ChatInput"; +import { SceneStage } from "./SceneStage"; + +type ImmersiveConversationProps = { + videoRef: RefObject; + videoStream?: MediaStream | null; + scene: SceneComposition | null; + backgrounds: SceneBackgroundAsset[]; + connection: ConnectionStatus; + sessionId: string | null; + subtitle: string | null; + isSpeaking: boolean; + ttsProvider: TtsProviderExtended; + sttProvider: string; + edgeVoice: string; + qwenModel: string; + qwenVoice: string; + onExit: () => void; + onSend: (text: string) => void; + onSpeakAudio: (blob: Blob) => void | Promise; + onSpeakFlashtalkAudioFile?: (file: File) => void | Promise; + onSpeakAudioStreamResult: (result: { text: string }) => void | Promise; + onSpeakAudioStreamError: (message: string) => void; + onInterrupt: () => void; + onNotify?: (message: string, tone?: "info" | "success" | "error") => void; +}; + +export function ImmersiveConversation({ + videoRef, + videoStream = null, + scene, + backgrounds, + connection, + sessionId, + subtitle, + isSpeaking, + ttsProvider, + sttProvider, + edgeVoice, + qwenModel, + qwenVoice, + onExit, + onSend, + onSpeakAudio, + onSpeakFlashtalkAudioFile, + onSpeakAudioStreamResult, + onSpeakAudioStreamError, + onInterrupt, + onNotify, +}: ImmersiveConversationProps) { + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const editing = target + ? ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) || target.isContentEditable + : false; + if (editing) return; + if (event.key === "Escape") onExit(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onExit]); + + const live = connection === "live" || connection === "expiring"; + + return ( +
+ +
+ +
+ + {live ? ( +
+ +
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/components/SceneStage.tsx b/apps/web/src/components/SceneStage.tsx new file mode 100644 index 0000000..5a1820b --- /dev/null +++ b/apps/web/src/components/SceneStage.tsx @@ -0,0 +1,138 @@ +import type { CSSProperties, ReactNode, RefObject } from "react"; +import type { SceneBackgroundAsset, SceneComposition } from "../lib/api"; +import { buildApiUrl } from "../lib/api"; +import { VideoBackground } from "./VideoBackground"; + +type SceneStageProps = { + videoRef: RefObject; + videoStream?: MediaStream | null; + scene: SceneComposition | null; + backgrounds: SceneBackgroundAsset[]; + subtitle?: string | null; + avatarMaskUrl?: string | null; + avatarAdjust?: { + x: number; + y: number; + scale: number; + }; + children?: ReactNode; + className?: string; + compactSquareStage?: boolean; +}; + +function backgroundUrl(background: SceneBackgroundAsset): string { + return buildApiUrl(background.url); +} + +const AVATAR_ANCHOR_CLASSES = { + center: "items-center justify-center", + bottom: "items-end justify-center", + left: "items-center justify-start", + right: "items-center justify-end", +} as const; + +const AVATAR_ANCHOR_OBJECT_POSITIONS = { + center: "object-center", + bottom: "object-[center_bottom]", + left: "object-[left_center]", + right: "object-[right_center]", +} as const; + +const AVATAR_ANCHOR_TRANSFORM_ORIGINS = { + center: "center", + bottom: "center bottom", + left: "left center", + right: "right center", +} as const; + +export function SceneStage({ + videoRef, + videoStream = null, + scene, + backgrounds, + subtitle, + avatarMaskUrl = null, + avatarAdjust, + children, + className = "", + compactSquareStage = false, +}: SceneStageProps) { + const background = scene?.background_id + ? backgrounds.find((item) => item.id === scene.background_id) ?? null + : null; + const subtitleStyle = scene?.subtitle_style ?? "lower-third"; + const avatarFit = scene?.avatar_fit === "cover" ? "object-cover" : "object-contain"; + const avatarAnchor = scene?.avatar_anchor ?? "center"; + const avatarAnchorClass = AVATAR_ANCHOR_CLASSES[avatarAnchor as keyof typeof AVATAR_ANCHOR_CLASSES] ?? AVATAR_ANCHOR_CLASSES.center; + const avatarObjectPosition = AVATAR_ANCHOR_OBJECT_POSITIONS[avatarAnchor as keyof typeof AVATAR_ANCHOR_OBJECT_POSITIONS] ?? AVATAR_ANCHOR_OBJECT_POSITIONS.center; + const avatarTransformOrigin = AVATAR_ANCHOR_TRANSFORM_ORIGINS[avatarAnchor as keyof typeof AVATAR_ANCHOR_TRANSFORM_ORIGINS] ?? AVATAR_ANCHOR_TRANSFORM_ORIGINS.center; + const avatarMaskSize = scene?.avatar_fit === "cover" ? "cover" : "contain"; + const avatarMaskPosition = avatarAnchor === "bottom" + ? "center bottom" + : avatarAnchor === "left" + ? "left center" + : avatarAnchor === "right" + ? "right center" + : "center"; + const hasSceneBackground = Boolean(scene); + const backgroundColor = scene?.background_color || "#ffffff"; + const sceneAvatarScale = scene?.avatar_scale ?? 1; + const avatarDisplayScale = sceneAvatarScale * (avatarAdjust?.scale ?? 1); + const avatarTransform = avatarAdjust + ? `translate(${avatarAdjust.x}px, ${avatarAdjust.y}px) scale(${avatarDisplayScale})` + : `scale(${sceneAvatarScale})`; + const avatarMaskStyle: CSSProperties | undefined = avatarMaskUrl + ? { + WebkitMaskImage: `url("${avatarMaskUrl}")`, + WebkitMaskRepeat: "no-repeat", + WebkitMaskSize: avatarMaskSize, + WebkitMaskPosition: avatarMaskPosition, + maskImage: `url("${avatarMaskUrl}")`, + maskMode: "alpha", + maskRepeat: "no-repeat", + maskSize: avatarMaskSize, + maskPosition: avatarMaskPosition, + } + : undefined; + + return ( +
+
+ {background?.kind === "image" ? ( + {background.name} + ) : null} + {background?.kind === "video" ? ( +
+ ); +} diff --git a/apps/web/src/components/TopBar.tsx b/apps/web/src/components/TopBar.tsx index 8eb077f..9985c29 100644 --- a/apps/web/src/components/TopBar.tsx +++ b/apps/web/src/components/TopBar.tsx @@ -29,10 +29,13 @@ const DOT_LABELS: Record = { export type FlashtalkRecordPhase = "idle" | "recording" | "stopped"; export type StudioWorkflow = "realtime" | "videoCreation" | "videoClone" | "assetLibrary" | "runtimeConfig"; +export type ConversationViewMode = "studio" | "immersive"; interface TopBarProps { connection: ConnectionStatus; workflow?: StudioWorkflow; + conversationViewMode?: ConversationViewMode; + immersiveChrome?: boolean; flashtalkRecording?: boolean; flashtalkRecordPhase?: FlashtalkRecordPhase; flashtalkRecordBusy?: boolean; @@ -40,6 +43,7 @@ interface TopBarProps { runtimeConfigReady?: boolean; runtimeConfigLoading?: boolean; onInactiveModuleClick?: (label: string) => void; + onConversationViewModeChange?: (mode: ConversationViewMode) => void; onFlashtalkRecordStart?: () => void; onFlashtalkRecordStop?: () => void; onFlashtalkRecordSave?: () => void; @@ -49,6 +53,8 @@ interface TopBarProps { export function TopBar({ connection, workflow = "realtime", + conversationViewMode = "studio", + immersiveChrome = false, flashtalkRecording = false, flashtalkRecordPhase = "idle", flashtalkRecordBusy = false, @@ -56,6 +62,7 @@ export function TopBar({ runtimeConfigReady = false, runtimeConfigLoading = false, onInactiveModuleClick, + onConversationViewModeChange, onFlashtalkRecordStart, onFlashtalkRecordStop, onFlashtalkRecordSave, @@ -64,7 +71,18 @@ export function TopBar({ const busy = flashtalkRecordBusy || recordingSaving; return ( -
+
+ {immersiveChrome ? ( +
+
+
+ ) : null}
@@ -80,7 +98,14 @@ export function TopBar({
-