diff --git a/doc/changelog.d/185.added.md b/doc/changelog.d/185.added.md new file mode 100644 index 00000000..41503ca2 --- /dev/null +++ b/doc/changelog.d/185.added.md @@ -0,0 +1 @@ +Remote rendering 4.2b: Choose viewer from server mode and show streamed frames diff --git a/src/ansys/visor/visor-client/src/App.tsx b/src/ansys/visor/visor-client/src/App.tsx index bd45ba5b..f5c72934 100644 --- a/src/ansys/visor/visor-client/src/App.tsx +++ b/src/ansys/visor/visor-client/src/App.tsx @@ -3,6 +3,8 @@ import { CSSProperties, RefObject, useEffect, useRef } from 'react'; import { randomInt } from './utils/JsHelpers'; import { useVisorArgs } from './hooks/useVisorArgs'; import { useClientVtk } from './hooks/useClientVtk'; +import { useRenderingMode } from './hooks/useRenderingMode'; +import { useServerVtk } from './hooks/useServerVtk'; function App() { const visorArgs = useVisorArgs(); @@ -22,7 +24,12 @@ function App() { margin: '0 auto', }; const uiScaffoldUtil: RefObject = useRef(null!); - const { visorFrontend: visorState, schemaMismatchError } = useClientVtk(visorArgs); + const probe = useRenderingMode(visorArgs.host, visorArgs.port); + const client = useClientVtk(visorArgs, probe.mode === 'client'); + const server = useServerVtk(probe.mode === 'server' ? probe.trame : null, visorArgs); + const active = probe.mode === 'server' ? server : client; + const { visorFrontend: visorState, schemaMismatchError } = active; + const { serverCanvas } = server; useEffect(() => { (async () => { @@ -44,12 +51,23 @@ function App() { return (
- {schemaMismatchError != null ? ( + {probe.error != null ? ( +
{probe.error.message}
+ ) : schemaMismatchError != null ? (
{`Scene details schema version mismatch: payload is version ` + `${schemaMismatchError.payloadVersion}, client expects version ` + `${schemaMismatchError.expectedVersion}.`}
+ ) : serverCanvas != null ? ( + // Server mode has no frontend to host the canvas, so its + // element is mounted here directly. +
{ + container?.replaceChildren(serverCanvas.element); + }} + /> ) : ( visorState != null && ( ; -/** Connect the wasm scene, rebuild the frontend on each server update, and apply set_state. */ -export function useClientVtk(visorArgs: VisorArgs): ClientVtk { +/** Connect the wasm scene, rebuild the frontend on each server update, and apply set_state; idle until active. */ +export function useClientVtk(visorArgs: VisorArgs, active: boolean): ClientVtk { const [visorFrontend, setVisorFrontend] = useState(null); const [schemaMismatchError, setSchemaMismatchError] = useState(null); @@ -24,6 +25,9 @@ export function useClientVtk(visorArgs: VisorArgs): ClientVtk { const cameraSyncRemover = useRef<(() => void) | null>(null); useEffect(() => { + if (!active) { + return; + } (async () => { if (wasmView.current == null) { const { host, port } = visorArgs; @@ -32,13 +36,13 @@ export function useClientVtk(visorArgs: VisorArgs): ClientVtk { wasmIdsStateKey: 'wasm_ids', refNameStateKey: 'wasm_ref_name', wasmUrl: `http${secure ? 's' : ''}://${host}:${port}/wasm`, - webSocketUrl: `ws${secure ? 's' : ''}://${host}:${port}/ws`, + webSocketUrl: trameWebSocketUrl(host, port), }); wasmView.current.addServerUpdatedListener(onServerUpdateAsync); await onServerUpdateAsync(); } })(); - }, []); + }, [active]); async function onServerUpdateAsync() { if (wasmView.current == null) { diff --git a/src/ansys/visor/visor-client/src/hooks/useRenderingMode.ts b/src/ansys/visor/visor-client/src/hooks/useRenderingMode.ts new file mode 100644 index 00000000..3a4568e3 --- /dev/null +++ b/src/ansys/visor/visor-client/src/hooks/useRenderingMode.ts @@ -0,0 +1,80 @@ +import { useEffect, useState } from 'react'; +import { Trame } from '@kitware/trame/src/trame.js'; +import { trameWebSocketUrl } from '../trameWebSocketUrl'; + +/** The rendering_mode wire values. */ +export type RenderingMode = 'client' | 'server'; + +/** Raised when the server's rendering_mode is missing or not a known wire value. */ +export class UnknownRenderingModeError extends Error { + constructor(value: unknown) { + super(`Unknown rendering_mode: ${JSON.stringify(value) ?? String(value)}`); + this.name = `UnknownRenderingModeError`; + this.value = value; + } + + readonly value: unknown; +} + +/** Map a rendering_mode state value to a mode; throws UnknownRenderingModeError for anything else. */ +export function parseRenderingMode(value: unknown): RenderingMode { + if (value === 'client' || value === 'server') { + return value; + } + throw new UnknownRenderingModeError(value); +} + +/** The probe's outcome: exactly one of mode and error is set once it settles; trame is set in server mode only. */ +export type RenderingModeProbe = Readonly<{ + mode: RenderingMode | null; + trame: Trame | null; + error: Error | null; +}>; + +/** Connect once on the page's ws/wss scheme, read rendering_mode, and keep the session only in server mode. */ +export function useRenderingMode(host: string, port: number): RenderingModeProbe { + const [probe, setProbe] = useState({ + mode: null, + trame: null, + error: null, + }); + + useEffect(() => { + // Under StrictMode the effect runs twice in development: each run owns + // its own connection, and a cancelled run drops its result. + let cancelled = false; + const trame = new Trame(); + (async () => { + try { + await trame.connect({ + application: 'trame', + sessionURL: trameWebSocketUrl(host, port), + }); + if (cancelled) { + trame.disconnect(); + return; + } + const mode = parseRenderingMode(trame.state?.get('rendering_mode')); + if (mode === 'client') { + // The client-mode scene opens its own connection. + trame.disconnect(); + setProbe({ mode, trame: null, error: null }); + } else { + setProbe({ mode, trame, error: null }); + } + } catch (err) { + trame.disconnect(); + if (!cancelled) { + const error = err instanceof Error ? err : new Error(String(err)); + setProbe({ mode: null, trame: null, error }); + } + } + })(); + return () => { + cancelled = true; + trame.disconnect(); + }; + }, [host, port]); + + return probe; +} diff --git a/src/ansys/visor/visor-client/src/hooks/useServerVtk.ts b/src/ansys/visor/visor-client/src/hooks/useServerVtk.ts new file mode 100644 index 00000000..9532da27 --- /dev/null +++ b/src/ansys/visor/visor-client/src/hooks/useServerVtk.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from 'react'; +import type { Trame } from '@kitware/trame/src/trame.js'; +import type { VisorFrontend } from '../VisorFrontend.tsx'; +import type { SchemaVersionMismatchError } from '../state/appstate/VisorSceneDetails.tsx'; +import { RCA_AREA_NAME, ServerCanvas, type RcaSession } from '../rca/ServerCanvas'; +import type { VisorArgs } from './useVisorArgs'; + +/** What the server-mode hook gives App. */ +export type ServerVtk = Readonly<{ + visorFrontend: VisorFrontend | null; + schemaMismatchError: SchemaVersionMismatchError | null; + serverCanvas: ServerCanvas | null; +}>; + +/** Over the probe's session: stream frames and send the page theme; idle when trame is null. */ +export function useServerVtk(trame: Trame | null, visorArgs: VisorArgs): ServerVtk { + const [serverCanvas, setServerCanvas] = useState(null); + + useEffect(() => { + if (trame == null) { + return; + } + // Subscribed before anything can make the server render, so the frame + // that render produces reaches the canvas. + const canvas = new ServerCanvas(sessionOf(trame), RCA_AREA_NAME); + setServerCanvas(canvas); + (async () => { + if (visorArgs.darkMode != null) { + await sendDarkThemeAsync(trame, visorArgs.darkMode); + } + })(); + return () => { + canvas.dispose(); + setServerCanvas(null); + }; + }, [trame]); + + return { visorFrontend: null, schemaMismatchError: null, serverCanvas }; +} + +/** The wslink session of a connected trame client. */ +function sessionOf(trame: Trame): RcaSession { + const session = trame.client?.getConnection()?.getSession(); + if (session == null) { + throw new Error(`trame has no open session`); + } + return session; +} + +/** Tell the server the theme the page shows, so its rendered background matches; a failed send is logged. */ +async function sendDarkThemeAsync(trame: Trame, dark: boolean): Promise { + try { + await trame.trigger('set_dark_theme', [{ dark }]); + } catch (err) { + console.error('[VISOR] set_dark_theme send failed', err); + } +} diff --git a/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx index 7bcd0459..91c6c1d4 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/AppDarkModeOverride.test.tsx @@ -25,6 +25,10 @@ jest.mock('../components/UiScaffold.tsx', () => ({ }, })); +jest.mock('../hooks/useRenderingMode', () => ({ + useRenderingMode: () => ({ mode: 'client', trame: null, error: null }), +})); + /** A minimal v4 scene-details payload whose delivered theme is light. */ function mockSceneDetailsPayload() { return { diff --git a/src/ansys/visor/visor-client/src/jest-tests/AppModeBranch.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/AppModeBranch.test.tsx new file mode 100644 index 00000000..d98202df --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/AppModeBranch.test.tsx @@ -0,0 +1,54 @@ +import { render, waitFor } from '@testing-library/react'; +import App from '../App'; + +/** + * When the probe reports server rendering, the page never starts the + * client-mode wasm scene: it streams frames over the probe's session instead. + * + * Expected values are hand-written literals. + */ + +const mockGetInstanceAsync = jest.fn(); +const mockSubscribe = jest.fn(() => ({ unsubscribe: () => {} })); +const mockTrame = { + client: { + getConnection: () => ({ + getSession: () => ({ subscribe: mockSubscribe, call: async () => undefined }), + }), + }, + trigger: async () => undefined, +}; + +jest.mock('../wasm/RemoteVtkScene', () => ({ + __esModule: true, + default: { getInstanceAsync: (...args: unknown[]) => mockGetInstanceAsync(...args) }, +})); + +jest.mock('../hooks/useRenderingMode', () => ({ + useRenderingMode: () => ({ mode: 'server', trame: mockTrame, error: null }), +})); + +const globals = globalThis as unknown as { ResizeObserver: unknown }; +let originalResizeObserver: unknown; + +beforeEach(() => { + originalResizeObserver = globals.ResizeObserver; + globals.ResizeObserver = class { + observe() {} + disconnect() {} + }; +}); + +afterEach(() => { + globals.ResizeObserver = originalResizeObserver; +}); + +describe('App mode branch', () => { + test('server mode never loads the wasm scene', async () => { + render(); + + await waitFor(() => expect(mockSubscribe).toHaveBeenCalled()); + + expect(mockGetInstanceAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/AppSchemaMismatch.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/AppSchemaMismatch.test.tsx index 33b814de..c609acdf 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/AppSchemaMismatch.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/AppSchemaMismatch.test.tsx @@ -23,6 +23,10 @@ jest.mock('../components/UiScaffold.tsx', () => ({ UiScaffold: () => null, })); +jest.mock('../hooks/useRenderingMode', () => ({ + useRenderingMode: () => ({ mode: 'client', trame: null, error: null }), +})); + /** The client-mode scene connection; its scene details carry schema version 3. */ function mockMakeWasmView() { return { diff --git a/src/ansys/visor/visor-client/src/jest-tests/RenderingModeProbe.test.ts b/src/ansys/visor/visor-client/src/jest-tests/RenderingModeProbe.test.ts new file mode 100644 index 00000000..762f3918 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/RenderingModeProbe.test.ts @@ -0,0 +1,97 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { + parseRenderingMode, + UnknownRenderingModeError, + useRenderingMode, +} from '../hooks/useRenderingMode'; + +/** + * The probe reads `rendering_mode` from one trame connection, keeps that + * connection only when the server renders, and reports anything other than + * the two wire values as a named error. + * + * Expected values are hand-written literals. + */ + +const mockProbe = { + renderingMode: undefined as unknown, + trame: null as null | { + connect: jest.Mock; + disconnect: jest.Mock; + state: { get: (key: string) => unknown }; + }, +}; + +jest.mock('@kitware/trame/src/trame.js', () => ({ + Trame: jest.fn().mockImplementation(() => { + mockProbe.trame = { + connect: jest.fn(async () => ({})), + disconnect: jest.fn(), + state: { + get: (key: string) => + key === 'rendering_mode' ? mockProbe.renderingMode : undefined, + }, + }; + return mockProbe.trame; + }), +})); + +beforeEach(() => { + mockProbe.renderingMode = undefined; + mockProbe.trame = null; +}); + +describe('parseRenderingMode', () => { + test('maps each wire value to its mode', () => { + expect(parseRenderingMode('client')).toBe('client'); + expect(parseRenderingMode('server')).toBe('server'); + }); + + test.each([['local'], [undefined], ['SERVER']])( + 'rejects %p with UnknownRenderingModeError carrying the value', + (value) => { + let thrown: unknown = null; + try { + parseRenderingMode(value); + } catch (err) { + thrown = err; + } + expect(thrown).toBeInstanceOf(UnknownRenderingModeError); + expect((thrown as UnknownRenderingModeError).value).toBe(value); + } + ); +}); + +describe('useRenderingMode', () => { + test('client mode disconnects the probe and hands on no session', async () => { + mockProbe.renderingMode = 'client'; + + const { result } = renderHook(() => useRenderingMode('localhost', 8080)); + await waitFor(() => expect(result.current.mode).toBe('client')); + + expect(mockProbe.trame!.disconnect).toHaveBeenCalledTimes(1); + expect(result.current.trame).toBeNull(); + }); + + test('server mode keeps the probe connection', async () => { + mockProbe.renderingMode = 'server'; + + const { result } = renderHook(() => useRenderingMode('localhost', 8080)); + await waitFor(() => expect(result.current.mode).toBe('server')); + + expect(mockProbe.trame!.disconnect).not.toHaveBeenCalled(); + expect(result.current.trame).toBe(mockProbe.trame); + }); + + test('an unknown mode is reported as an error and the probe disconnected', async () => { + mockProbe.renderingMode = 'local'; + + const { result } = renderHook(() => useRenderingMode('localhost', 8080)); + await waitFor(() => expect(result.current.error).not.toBeNull()); + + expect(result.current.error).toBeInstanceOf(UnknownRenderingModeError); + expect(result.current.mode).toBeNull(); + expect(result.current.trame).toBeNull(); + expect(mockProbe.trame!.disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/ServerCanvasStream.test.ts b/src/ansys/visor/visor-client/src/jest-tests/ServerCanvasStream.test.ts new file mode 100644 index 00000000..a0f0f426 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/ServerCanvasStream.test.ts @@ -0,0 +1,134 @@ +import { RCA_AREA_NAME, ServerCanvas } from '../rca/ServerCanvas'; + +/** + * The server canvas shows only the frames addressed to its own area, from the + * frame bytes alone, and reports its element's size to the server, which is + * what starts the stream. + * + * jsdom has neither `ResizeObserver` nor `URL.createObjectURL`; both are + * replaced by doubles that record what they are given. + * + * Expected values are hand-written literals. + */ + +type StreamCallback = (args: unknown[]) => void; + +const resizeCallbacks: ((entries: unknown[]) => void)[] = []; +const createdBlobs: Blob[] = []; +const globals = globalThis as unknown as { ResizeObserver: unknown }; +const urlStatics = URL as unknown as { + createObjectURL: unknown; + revokeObjectURL: unknown; +}; +let originalResizeObserver: unknown; +let originalCreateObjectURL: unknown; +let originalRevokeObjectURL: unknown; + +/** A session double that keeps the stream callback and records calls. */ +function makeSession() { + const session = { + streamCallback: null as StreamCallback | null, + topics: [] as string[], + unsubscribe: jest.fn(), + call: jest.fn(async () => undefined), + subscribe(topic: string, callback: StreamCallback) { + session.topics.push(topic); + session.streamCallback = callback; + return { unsubscribe: session.unsubscribe }; + }, + }; + return session; +} + +/** Report one element size to every observer created so far. */ +function observeSize(width: number, height: number) { + for (const callback of resizeCallbacks) { + callback([{ contentRect: { width, height } }]); + } +} + +beforeEach(() => { + resizeCallbacks.length = 0; + createdBlobs.length = 0; + originalResizeObserver = globals.ResizeObserver; + globals.ResizeObserver = class { + constructor(callback: (entries: unknown[]) => void) { + resizeCallbacks.push(callback); + } + observe() {} + disconnect() {} + }; + originalCreateObjectURL = urlStatics.createObjectURL; + originalRevokeObjectURL = urlStatics.revokeObjectURL; + urlStatics.createObjectURL = (blob: Blob) => { + createdBlobs.push(blob); + return `blob:frame-${createdBlobs.length}`; + }; + urlStatics.revokeObjectURL = () => {}; +}); + +afterEach(() => { + globals.ResizeObserver = originalResizeObserver; + urlStatics.createObjectURL = originalCreateObjectURL; + urlStatics.revokeObjectURL = originalRevokeObjectURL; +}); + +describe('ServerCanvas stream', () => { + test('RCA_AREA_NAME is "view"', () => { + expect(RCA_AREA_NAME).toBe('view'); + }); + + test('a frame for its own area creates one object URL from the content bytes only', () => { + const session = makeSession(); + new ServerCanvas(session, 'view'); + // Three frame bytes inside an eight-byte message buffer. + const content = new Uint8Array(new ArrayBuffer(8), 2, 3); + + session.streamCallback!([{ name: 'view', meta: {}, content }]); + + expect(session.topics).toEqual(['trame.rca.topic.stream']); + expect(createdBlobs.length).toBe(1); + expect(createdBlobs[0].size).toBe(3); + }); + + test('a frame for another area creates none', () => { + const session = makeSession(); + new ServerCanvas(session, 'view'); + + session.streamCallback!([{ name: 'other', meta: {}, content: new Uint8Array(3) }]); + + expect(createdBlobs.length).toBe(0); + }); + + test('dispose unsubscribes from the stream', () => { + const session = makeSession(); + const canvas = new ServerCanvas(session, 'view'); + + canvas.dispose(); + + expect(session.unsubscribe).toHaveBeenCalledTimes(1); + }); + + test('a non-zero element size is sent to the area', () => { + const session = makeSession(); + new ServerCanvas(session, 'view'); + + observeSize(400.4, 300); + + expect(session.call).toHaveBeenCalledTimes(1); + expect(session.call).toHaveBeenCalledWith('trame.rca.size', [ + 'view', + 'ServerCanvas', + { w: 400, h: 300, p: 1 }, + ]); + }); + + test('a zero element size sends nothing', () => { + const session = makeSession(); + new ServerCanvas(session, 'view'); + + observeSize(0, 300); + + expect(session.call).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx index 4624c4f2..ad59a385 100644 --- a/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx +++ b/src/ansys/visor/visor-client/src/jest-tests/UseClientVtkCameraSync.test.tsx @@ -167,7 +167,7 @@ afterEach(() => { describe('useClientVtk camera sync', () => { test('one gesture settle after two rebuilds sends one sync_camera', async () => { - const { result } = renderHook(() => useClientVtk(ARGS)); + const { result } = renderHook(() => useClientVtk(ARGS, true)); await waitFor(() => expect(result.current.visorFrontend).not.toBeNull()); await act(async () => { await mockScene.serverUpdated!(); @@ -187,7 +187,7 @@ describe('useClientVtk camera sync', () => { test('a settle during the first build state seed is reported once', async () => { mockScene.settleDuringFirstSeed = true; - const { result } = renderHook(() => useClientVtk(ARGS)); + const { result } = renderHook(() => useClientVtk(ARGS, true)); await waitFor(() => expect(result.current.visorFrontend).not.toBeNull()); await act(async () => { await drain(); diff --git a/src/ansys/visor/visor-client/src/jest-tests/UseServerVtkTheme.test.tsx b/src/ansys/visor/visor-client/src/jest-tests/UseServerVtkTheme.test.tsx new file mode 100644 index 00000000..f5b33063 --- /dev/null +++ b/src/ansys/visor/visor-client/src/jest-tests/UseServerVtkTheme.test.tsx @@ -0,0 +1,74 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import type { Trame } from '@kitware/trame/src/trame.js'; +import { useServerVtk } from '../hooks/useServerVtk'; +import type { VisorArgs } from '../hooks/useVisorArgs'; + +/** + * In server mode the page's theme reaches the server once, after the canvas + * has subscribed to the frame stream, so the frame the theme change renders + * is not lost. With no theme from the page, nothing is sent. + * + * Expected values are hand-written literals. + */ + +const events: string[] = []; +const trigger = jest.fn(async (name: string) => { + events.push(name); +}); +const session = { + subscribe: (topic: string) => { + events.push(`subscribe ${topic}`); + return { unsubscribe: () => {} }; + }, + call: async () => undefined, +}; +const trame = { + client: { getConnection: () => ({ getSession: () => session }) }, + trigger, +} as unknown as Trame; + +const globals = globalThis as unknown as { ResizeObserver: unknown }; +let originalResizeObserver: unknown; + +function argsWith(darkMode: boolean | undefined): VisorArgs { + return { + host: 'localhost', + port: 8080, + aspectRatio: 1, + pixelDensity: 1000, + basePath: '', + darkMode, + } as VisorArgs; +} + +beforeEach(() => { + events.length = 0; + trigger.mockClear(); + originalResizeObserver = globals.ResizeObserver; + globals.ResizeObserver = class { + observe() {} + disconnect() {} + }; +}); + +afterEach(() => { + globals.ResizeObserver = originalResizeObserver; +}); + +describe('useServerVtk theme', () => { + test('a darkMode prop is sent once, after the canvas subscribes', async () => { + renderHook(() => useServerVtk(trame, argsWith(true))); + await waitFor(() => expect(trigger).toHaveBeenCalled()); + + expect(trigger).toHaveBeenCalledTimes(1); + expect(trigger).toHaveBeenCalledWith('set_dark_theme', [{ dark: true }]); + expect(events).toEqual(['subscribe trame.rca.topic.stream', 'set_dark_theme']); + }); + + test('no darkMode prop sends no set_dark_theme', async () => { + const { result } = renderHook(() => useServerVtk(trame, argsWith(undefined))); + await waitFor(() => expect(result.current.serverCanvas).not.toBeNull()); + + expect(trigger).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ansys/visor/visor-client/src/rca/ServerCanvas.ts b/src/ansys/visor/visor-client/src/rca/ServerCanvas.ts new file mode 100644 index 00000000..7f043766 --- /dev/null +++ b/src/ansys/visor/visor-client/src/rca/ServerCanvas.ts @@ -0,0 +1,161 @@ +/** The RCA area name; must equal RCA_AREA_NAME in app/trame/server_app.py. */ +export const RCA_AREA_NAME = 'view'; + +/** The trame-rca topic every area's frames are published on. */ +const STREAM_TOPIC = 'trame.rca.topic.stream'; + +/** The origin this canvas reports in its size messages. */ +const SIZE_ORIGIN = 'ServerCanvas'; + +/** The part of a wslink session the canvas uses. */ +export type RcaSession = { + subscribe( + topic: string, + callback: (args: unknown[]) => Promise + ): { unsubscribe(): unknown }; + call(method: string, args: unknown[]): Promise; +}; + +/** Pixel size of the last frame drawn. */ +export type FrameSize = Readonly<{ width: number; height: number }>; + +/** What ServerRenderer needs from the canvas. */ +export interface IServerCanvas { + readonly element: HTMLDivElement; + readonly frameSize: FrameSize | null; + addFrameListener(callback: (fps: number) => void): () => void; +} + +/** One message on the stream topic: an encoded frame for one area. */ +type StreamMessage = Readonly<{ name?: unknown; content?: unknown }>; + +/** Shows the frames the server streams for one RCA area, and reports the area's size to the server. */ +export class ServerCanvas implements IServerCanvas { + /** Subscribe to the area's frame stream on session, draw each frame into element, and report element's size. */ + constructor(session: RcaSession, areaName: string) { + this.#session = session; + this.#areaName = areaName; + + const element = document.createElement('div'); + element.style.cssText = `position:relative;width:100%;height:100%;overflow:hidden;`; + const image = document.createElement('img'); + image.style.cssText = `display:block;width:100%;height:100%;`; + image.draggable = false; + image.addEventListener('load', this.#onImageLoad); + image.addEventListener('error', this.#onImageError); + element.appendChild(image); + this.element = element; + this.#image = image; + + this.#subscription = session.subscribe(STREAM_TOPIC, async (args) => this.#onStream(args)); + // The first non-zero observation is what starts the stream: the server + // renders a frame for each size it is sent. + this.#resizeObserver = new ResizeObserver(this.#onResize); + this.#resizeObserver.observe(element); + } + + /** The element that shows the frames. */ + readonly element: HTMLDivElement; + + /** Natural size of the last decoded frame, or null before the first. */ + get frameSize(): FrameSize | null { + return this.#frameSize; + } + + /** Call callback once per drawn frame with frames per second over the last second; returns its remover. */ + addFrameListener(callback: (fps: number) => void): () => void { + const remover = () => { + this.#frameListeners.delete(remover); + }; + this.#frameListeners.set(remover, callback); + return remover; + } + + /** Unsubscribe, stop observing the size, remove DOM listeners and revoke the current frame URL. */ + dispose(): void { + this.#subscription.unsubscribe(); + this.#resizeObserver.disconnect(); + this.#image.removeEventListener('load', this.#onImageLoad); + this.#image.removeEventListener('error', this.#onImageError); + this.#frameListeners.clear(); + this.#revokeFrameUrl(); + } + + readonly #session: RcaSession; + readonly #areaName: string; + readonly #image: HTMLImageElement; + readonly #subscription: { unsubscribe(): unknown }; + readonly #resizeObserver: ResizeObserver; + readonly #frameListeners = new Map<() => void, (fps: number) => void>(); + readonly #frameTimes: number[] = []; + #frameSize: FrameSize | null = null; + #frameUrl: string | null = null; + + /** Show each frame addressed to this canvas's area. */ + #onStream = (args: unknown[]): void => { + for (const message of args as StreamMessage[]) { + if (message == null || message.name !== this.#areaName) { + continue; + } + const content = message.content; + if (!(content instanceof Uint8Array)) { + console.error('[VISOR] frame content is not binary'); + continue; + } + this.#showFrame(content as Uint8Array); + } + }; + + /** Show one encoded frame from the bytes inside the view only. */ + #showFrame(content: Uint8Array): void { + // A decoded message's binary field is a view into the whole message + // buffer, so `content.buffer` would carry the framing bytes as well + // and the image would fail to decode without an error. + const blob = new Blob([content], { type: 'image/jpeg' }); + this.#revokeFrameUrl(); + this.#frameUrl = URL.createObjectURL(blob); + this.#image.src = this.#frameUrl; + } + + #revokeFrameUrl(): void { + if (this.#frameUrl != null) { + URL.revokeObjectURL(this.#frameUrl); + this.#frameUrl = null; + } + } + + /** Record the drawn frame's size and report the frame rate over the last second. */ + #onImageLoad = (): void => { + const image = this.#image; + this.#frameSize = { width: image.naturalWidth, height: image.naturalHeight }; + const now = performance.now(); + const frameTimes = this.#frameTimes; + frameTimes.push(now); + while (frameTimes.length > 0 && frameTimes[0] < now - 1000) { + frameTimes.shift(); + } + const fps = frameTimes.length; + for (const callback of this.#frameListeners.values()) { + callback(fps); + } + }; + + #onImageError = (): void => { + console.error('[VISOR] frame decode failed'); + }; + + /** Send each non-zero element size to the area; the server resizes its window and renders a frame. */ + #onResize = (entries: ResizeObserverEntry[]): void => { + for (const entry of entries) { + const w = Math.round(entry.contentRect.width); + const h = Math.round(entry.contentRect.height); + if (w === 0 || h === 0) { + continue; + } + const p = window.devicePixelRatio ?? 1; + this.#session + .call('trame.rca.size', [this.#areaName, SIZE_ORIGIN, { w, h, p }]) + .catch((err) => console.error('[VISOR] trame.rca.size send failed', err)); + } + }; +} diff --git a/src/ansys/visor/visor-client/src/trameWebSocketUrl.ts b/src/ansys/visor/visor-client/src/trameWebSocketUrl.ts new file mode 100644 index 00000000..638000e0 --- /dev/null +++ b/src/ansys/visor/visor-client/src/trameWebSocketUrl.ts @@ -0,0 +1,5 @@ +/** The trame websocket URL for a host and port, on the page's scheme. */ +export function trameWebSocketUrl(host: string, port: number): string { + const secure = window.location.protocol === 'https:'; + return `ws${secure ? 's' : ''}://${host}:${port}/ws`; +}