started panzoom thing

This commit is contained in:
John Lancaster
2026-06-28 22:10:06 -05:00
parent 8064821503
commit ca5c9f787f
5 changed files with 330 additions and 2 deletions
+9 -1
View File
@@ -8,6 +8,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi import status
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from .api.errors import register_error_handlers
from .api.health import router as health_router
@@ -25,7 +26,7 @@ from .worker import worker_consumer_lifespan
async def _lifespan(app: FastAPI):
configure_logging()
settings = get_settings()
settings = getattr(app.state, "settings", None) or get_settings()
app.state.settings = settings
app.state.services = ServiceBundle()
app.state.runtime = initialize_database_runtime(settings=settings)
@@ -52,6 +53,13 @@ async def _lifespan(app: FastAPI):
def create_app() -> FastAPI:
"""Create and configure the FastAPI application."""
app = FastAPI(title="Transcription", lifespan=_lifespan)
settings = get_settings()
app.state.settings = settings
app.mount(
"/uploads",
StaticFiles(directory=settings.upload_dir, check_dir=False),
name="uploads",
)
@app.get("/", include_in_schema=False)
async def root_redirect() -> RedirectResponse:
+2 -1
View File
@@ -2,5 +2,6 @@
from transcription.ui.components.app_shell import NAV_ITEMS
from transcription.ui.components.app_shell import render_navigation_header
from transcription.ui.components.document_panzoom import render_document_panzoom
__all__ = ["NAV_ITEMS", "render_navigation_header"]
__all__ = ["NAV_ITEMS", "render_document_panzoom", "render_navigation_header"]
@@ -0,0 +1,236 @@
"""Panzoom-backed document preview component."""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote
from uuid import uuid4
from nicegui import ui
from transcription.config import get_settings
from transcription.models import Document
PANGOZOOM_CDN_URL = "https://unpkg.com/@panzoom/[email protected]/dist/panzoom.min.js"
UPLOADS_URL_PREFIX = "/uploads"
def _document_url(document: Document) -> str:
file_path = Path(document.file_path)
upload_dir = get_settings().upload_dir
relative_path: Path
try:
relative_path = file_path.resolve().relative_to(upload_dir.resolve())
except ValueError:
parts = file_path.parts
if "uploads" in parts:
uploads_index = parts.index("uploads")
relative_path = Path(*parts[uploads_index + 1 :])
else:
relative_path = Path(file_path.name)
encoded_relative_path = "/".join(quote(part) for part in relative_path.parts)
return f"{UPLOADS_URL_PREFIX}/{encoded_relative_path}"
def _document_kind(document: Document) -> str:
suffix = Path(document.file_path).suffix.lower()
if suffix == ".pdf":
return "pdf"
return "image"
@lru_cache(maxsize=1)
def _register_panzoom_assets() -> None:
ui.add_head_html(
f'<script src="{PANGOZOOM_CDN_URL}"></script>',
shared=True,
)
ui.add_head_html(
"""
<style>
.document-panzoom-host {
overflow: hidden;
touch-action: none;
}
.document-panzoom-surface {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.document-panzoom-media {
width: auto;
height: auto;
display: block;
max-width: none;
max-height: none;
user-select: none;
-webkit-user-drag: none;
}
.document-panzoom-iframe {
width: 100%;
height: 100%;
border: 0;
pointer-events: none;
background: white;
}
</style>
""",
shared=True,
)
def _attach_panzoom(host_id: str) -> None:
ui.run_javascript(
f"""
(function() {{
if (!window.Panzoom) return;
window.__transcriptionPanzoom = window.__transcriptionPanzoom || {{}};
const host = document.getElementById({host_id!r});
if (!host) return;
const target = host.querySelector('[data-panzoom-target]');
const media = host.querySelector('[data-panzoom-media]');
if (!target) return;
const computeFitScale = () => {{
const hostRect = host.getBoundingClientRect();
if (hostRect.width <= 0 || hostRect.height <= 0) return 1;
if (media && media.tagName === 'IMG') {{
if (media.naturalWidth <= 0 || media.naturalHeight <= 0) return 1;
return Math.min(
hostRect.width / media.naturalWidth,
hostRect.height / media.naturalHeight
);
}}
if (target.tagName === 'IFRAME') {{
const targetRect = target.getBoundingClientRect();
if (targetRect.width <= 0 || targetRect.height <= 0) return 1;
return hostRect.height / targetRect.height;
}}
return 1;
}};
const initPanzoom = () => {{
if (window.__transcriptionPanzoom[{host_id!r}]) {{
window.__transcriptionPanzoom[{host_id!r}].destroy();
}}
if (host.__transcriptionWheelHandler) {{
host.removeEventListener('wheel', host.__transcriptionWheelHandler);
host.__transcriptionWheelHandler = null;
}}
let fitScale = computeFitScale();
if (!Number.isFinite(fitScale) || fitScale <= 0) {{
fitScale = 1;
}}
const startScale = fitScale;
const minScale = 0.1;
const instance = Panzoom(target, {{
maxScale: 32,
minScale: minScale,
startScale: startScale,
step: 0.18,
contain: 'inside',
roundPixels: true,
overflow: 'hidden',
}});
window.__transcriptionPanzoom[{host_id!r}] = instance;
requestAnimationFrame(() => {{
requestAnimationFrame(() => {{
instance.reset({{ animate: false, force: true }});
}});
}});
const wheelHandler = (event) => instance.zoomWithWheel(event);
host.__transcriptionWheelHandler = wheelHandler;
host.addEventListener('wheel', wheelHandler, {{ passive: false }});
}};
if (media && media.tagName === 'IMG' && !media.complete) {{
media.addEventListener('load', initPanzoom, {{ once: true }});
return;
}}
if ((media && media.tagName === 'IMG' && (host.clientWidth <= 0 || host.clientHeight <= 0)) ||
(target.tagName === 'IFRAME' && (host.clientWidth <= 0 || host.clientHeight <= 0))) {{
requestAnimationFrame(initPanzoom);
return;
}}
initPanzoom();
}})();
"""
)
def render_document_panzoom(*, document: Document, height: str = "640px") -> None:
"""Render a document preview with pan and zoom interactions."""
_register_panzoom_assets()
host_id = f"document-panzoom-{uuid4().hex}"
document_url = _document_url(document)
document_kind = _document_kind(document)
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
with ui.row().classes("w-full items-center justify-between q-gutter-sm"):
with ui.column().classes("q-gutter-none"):
ui.label("Document preview").classes("text-subtitle1 text-weight-medium")
ui.label(document.filename).classes("text-caption text-grey-4")
with ui.button_group().props("flat outline"):
ui.button(
"Zoom In",
icon="add",
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.zoomIn()"),
)
ui.button(
"Zoom Out",
icon="remove",
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.zoomOut()"),
)
ui.button(
"Reset",
icon="restart_alt",
on_click=lambda: ui.run_javascript(f"window.__transcriptionPanzoom[{host_id!r}]?.reset()"),
)
ui.label("Scroll, pinch, or use the buttons to inspect the document.").classes(
"text-caption text-grey-4 q-mt-sm"
)
with (
ui.element("div")
.classes("w-full document-panzoom-host bg-blue-grey-9 rounded-borders q-mt-md")
.style(f"height: {height};") as host
):
host.props(f"id={host_id}")
with ui.element("div").classes("document-panzoom-surface"):
if document_kind == "pdf":
ui.html(
f'<iframe class="document-panzoom-iframe" '
f'src="{document_url}" title="{document.filename}" '
"data-panzoom-target></iframe>"
)
else:
ui.html(
f'<img class="document-panzoom-media" '
f'src="{document_url}" alt="{document.filename}" '
"data-panzoom-target data-panzoom-media />"
)
_attach_panzoom(host_id)
@@ -7,6 +7,7 @@ from nicegui import ui
from transcription.models import Document
from transcription.models import Job
from transcription.models import Transcript
from transcription.ui.components.document_panzoom import render_document_panzoom
def _status_chip_classes(status: str) -> str:
@@ -35,6 +36,9 @@ def _render_document_section(document: Document) -> None:
_metadata_row("Filename", document.filename)
_metadata_row("File path", document.file_path)
ui.separator().classes("q-my-md bg-blue-grey-7")
render_document_panzoom(document=document)
def _render_transcript_section(transcript: Transcript | None) -> None:
with ui.card().classes("w-full bg-blue-grey-10 text-grey-1 q-pa-md"):
+79
View File
@@ -0,0 +1,79 @@
"""Shared fixtures for UI integration tests."""
from __future__ import annotations
import asyncio
from collections.abc import Callable
from pathlib import Path
from uuid import UUID
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from transcription.app import create_app
from transcription.config import Settings
from transcription.config import _settings
from transcription.db import get_session
from transcription.models import Document
from transcription.models import Job
from transcription.models import JobStatus
from transcription.models import Transcript
@pytest.fixture
def app_client(tmp_path: Path) -> tuple[FastAPI, TestClient]:
"""Provide a real application and test client backed by in-memory SQLite."""
settings = Settings(
openrouter_api_key="test-key",
database_url="sqlite:///:memory:",
environment="test",
upload_dir=tmp_path / "uploads",
prompt_dir=tmp_path / "prompts",
)
_settings.set(settings)
app = create_app()
with TestClient(app) as client:
yield app, client
@pytest.fixture
def seed_job(app_client: tuple[FastAPI, TestClient]) -> Callable[..., UUID]:
"""Return a helper for inserting a document/job/transcript trio."""
app, _ = app_client
def _seed(
*,
filename: str = "sample.pdf",
status: JobStatus = JobStatus.TRANSCRIBED,
transcript_text: str | None = "Sample transcript text",
error_detail: str | None = None,
) -> UUID:
async def _insert() -> UUID:
async with get_session(session_factory=app.state.runtime.session_factory) as session:
document = Document(filename=filename, file_path=f"uploads/{filename}")
session.add(document)
await session.flush()
job = Job(document_id=document.id, status=status, retry_count=0)
session.add(job)
await session.flush()
if transcript_text is not None or error_detail is not None:
session.add(
Transcript(
job_id=job.id,
provider="openrouter",
prompt_name="transcribe_document",
text=transcript_text,
error_detail=error_detail,
)
)
await session.commit()
return job.id
return asyncio.run(_insert())
return _seed