连接或选择一张全景图
+将画布中的图片连到此节点,或从本地选择 2:1 等距柱状投影图片。
+From f168687016b60fdf3a0b577971c0328ee0596fc0 Mon Sep 17 00:00:00 2001 From: superche <35420957+superche@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:48:36 +0800 Subject: [PATCH] feat(plugin): publish panorama viewer --- bun.lock | 4 + docs/plugin-authoring.md | 24 +- .../panorama-viewer/convax-package.json | 14 + packages/plugins/panorama-viewer/package.json | 10 + .../plugins/panorama-viewer/package/LICENSE | 21 + .../panorama-viewer/package/assets/app.js | 1013 +++++++++++++++++ .../package/assets/panorama-image.js | 171 +++ .../package/assets/panorama-renderer.js | 217 ++++ .../panorama-viewer/package/assets/styles.css | 765 +++++++++++++ .../panorama-viewer/package/index.html | 169 +++ .../panorama-viewer/package/manifest.json | 45 + registry/config.json | 2 +- schemas/convax-plugin-manifest-v1.schema.json | 4 +- schemas/convax-plugin-manifest-v2.schema.json | 3 +- schemas/convax-plugin-manifest-v3.schema.json | 3 +- schemas/convax-plugin-manifest-v4.schema.json | 3 +- schemas/convax-plugin-manifest-v5.schema.json | 3 +- tooling/lib.mjs | 1 + tooling/panorama-viewer.test.js | 86 ++ tooling/registry.test.js | 18 + 20 files changed, 2560 insertions(+), 16 deletions(-) create mode 100644 packages/plugins/panorama-viewer/convax-package.json create mode 100644 packages/plugins/panorama-viewer/package.json create mode 100644 packages/plugins/panorama-viewer/package/LICENSE create mode 100644 packages/plugins/panorama-viewer/package/assets/app.js create mode 100644 packages/plugins/panorama-viewer/package/assets/panorama-image.js create mode 100644 packages/plugins/panorama-viewer/package/assets/panorama-renderer.js create mode 100644 packages/plugins/panorama-viewer/package/assets/styles.css create mode 100644 packages/plugins/panorama-viewer/package/index.html create mode 100644 packages/plugins/panorama-viewer/package/manifest.json create mode 100644 tooling/panorama-viewer.test.js diff --git a/bun.lock b/bun.lock index 1edc0d2..adebd18 100644 --- a/bun.lock +++ b/bun.lock @@ -29,6 +29,10 @@ "name": "@microvoid/convax-plugin-multi-angle", "version": "0.1.0" }, + "packages/plugins/panorama-viewer": { + "name": "@microvoid/convax-plugin-panorama-viewer", + "version": "0.2.0" + }, "packages/plugins/relight-studio": { "name": "@microvoid/convax-plugin-relight-studio", "version": "0.1.2", diff --git a/docs/plugin-authoring.md b/docs/plugin-authoring.md index ce7a3af..bca75ed 100644 --- a/docs/plugin-authoring.md +++ b/docs/plugin-authoring.md @@ -311,20 +311,26 @@ arrive as `{"protocol":"convax.plugin-host/1","type":"command","command":"refres ## Capabilities -| Method | Manifest capability | Scope | -| --------------------------- | -------------------- | --------------------------------------------------- | -| `host.context.get` | none | current Project, Canvas, and owning node | -| `canvas.node.get` | `canvas.node.read` | owning node only | -| `canvas.node.updateState` | `canvas.node.write` | Plugin-namespaced node state | -| `project.file.readText` | `project.files.read` | current Project-relative text file | -| `agent.prompt` | `agent.prompt` | current Project and owning node resource | -| `generation.tools.list` | `generation.execute` | installed generation contracts in the current scope | -| `generation.canvas.execute` | `generation.execute` | shared scoped Canvas generation operation | +| Method | Manifest capability | Scope | +| ----------------------------- | ----------------------------- | ------------------------------------------------------------ | +| `host.context.get` | none | current Project, Canvas, and owning node | +| `canvas.node.get` | `canvas.node.read` | owning node only | +| `canvas.node.updateState` | `canvas.node.write` | Plugin-namespaced node state | +| `canvas.connectedImages.list` | `canvas.connectedImages.read` | directly connected managed Canvas image nodes | +| `canvas.connectedImages.read` | `canvas.connectedImages.read` | bounded bytes for one directly connected managed image | +| `canvas.image.create` | `canvas.image.write` | one bounded PNG imported as a managed adjacent Canvas image | +| `project.file.readText` | `project.files.read` | current Project-relative text file | +| `agent.prompt` | `agent.prompt` | current Project and owning node resource | +| `generation.tools.list` | `generation.execute` | installed generation contracts in the current scope | +| `generation.canvas.execute` | `generation.execute` | shared scoped Canvas generation operation | Request the smallest set. Arguments cannot select another Project, Canvas, or node. Treat results as untrusted structured data, bound message sizes, handle errors, and render a useful disconnected state. A successful domain mutation may be followed by a failed optional view effect; do not report that as a reverted mutation. +`canvas.image.create` accepts a bounded PNG data URL and a portable display name; +the host owns asset admission, node placement, the connection from the Plugin node, +persistence, and rollback if the Canvas commit fails. ## Forbidden behavior diff --git a/packages/plugins/panorama-viewer/convax-package.json b/packages/plugins/panorama-viewer/convax-package.json new file mode 100644 index 0000000..5c8850e --- /dev/null +++ b/packages/plugins/panorama-viewer/convax-package.json @@ -0,0 +1,14 @@ +{ + "schema": "convax.package/1", + "kind": "plugin", + "id": "panorama-viewer", + "name": "全景图预览", + "description": "预览等距柱状投影 360° 全景图,支持拖拽环视、滚轮缩放、自动旋转、全屏、本地文件及画布连接图片。", + "version": "0.2.0", + "license": "MIT", + "compatibility": { + "pluginSchema": "convax.plugin/1", + "pluginHost": "convax.plugin-host/1" + }, + "yanked": false +} diff --git a/packages/plugins/panorama-viewer/package.json b/packages/plugins/panorama-viewer/package.json new file mode 100644 index 0000000..125c637 --- /dev/null +++ b/packages/plugins/panorama-viewer/package.json @@ -0,0 +1,10 @@ +{ + "name": "@microvoid/convax-plugin-panorama-viewer", + "version": "0.2.0", + "private": true, + "type": "module", + "scripts": { + "validate": "bun ../../../tooling/validate.mjs --kind plugin --id panorama-viewer", + "pack": "bun ../../../tooling/pack.mjs --kind plugin --id panorama-viewer" + } +} diff --git a/packages/plugins/panorama-viewer/package/LICENSE b/packages/plugins/panorama-viewer/package/LICENSE new file mode 100644 index 0000000..0260f10 --- /dev/null +++ b/packages/plugins/panorama-viewer/package/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Microvoid contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/plugins/panorama-viewer/package/assets/app.js b/packages/plugins/panorama-viewer/package/assets/app.js new file mode 100644 index 0000000..b53b4fc --- /dev/null +++ b/packages/plugins/panorama-viewer/package/assets/app.js @@ -0,0 +1,1013 @@ +import { + ACCEPTED_IMAGE_TYPES, + MAX_IMAGE_FILE_BYTES, + decodePanoramaImage, + inspectDataUrlImage, + inspectImageBytes, +} from "./panorama-image.js" +import { createPanoramaRenderer } from "./panorama-renderer.js" + +const HOST_PROTOCOL = "convax.plugin-host/1" +const PLUGIN_ID = "panorama-viewer" +const CONNECTIONS_CHANGED_COMMAND = "canvas.connectedImages.changed" +const MIN_FOV = 30 +const MAX_FOV = 100 +const MAX_PITCH = 89 +const AUTO_ROTATE_DEGREES_PER_SECOND = 3 +const STATE_SAVE_DELAY = 320 +const STATE_SAVE_MAX_ATTEMPTS = 3 +const REQUEST_TIMEOUT = 30000 + +const elements = { + app: document.getElementById("app"), + autoRotateButton: document.getElementById("autoRotateButton"), + canvas: document.getElementById("panoramaCanvas"), + captureButton: document.getElementById("captureButton"), + chooseButton: document.getElementById("chooseButton"), + connectionPill: document.getElementById("connectionPill"), + connectionText: document.getElementById("connectionText"), + dragHint: document.getElementById("dragHint"), + emptyChooseButton: document.getElementById("emptyChooseButton"), + emptyDescription: document.getElementById("emptyDescription"), + emptyState: document.getElementById("emptyState"), + emptyTitle: document.getElementById("emptyTitle"), + fileInput: document.getElementById("fileInput"), + fovOutput: document.getElementById("fovOutput"), + fovRange: document.getElementById("fovRange"), + fullscreenButton: document.getElementById("fullscreenButton"), + fullscreenLabel: document.getElementById("fullscreenLabel"), + imageDimensions: document.getElementById("imageDimensions"), + imageMeta: document.getElementById("imageMeta"), + imageRatio: document.getElementById("imageRatio"), + interactionHint: document.getElementById("interactionHint"), + loadingState: document.getElementById("loadingState"), + loadingText: document.getElementById("loadingText"), + refreshButton: document.getElementById("refreshButton"), + resetButton: document.getElementById("resetButton"), + sourceSelect: document.getElementById("sourceSelect"), + sourceSelectShell: document.getElementById("sourceSelectShell"), + sourceStatus: document.getElementById("sourceStatus"), + toast: document.getElementById("toast"), + viewer: document.getElementById("viewer"), +} + +let hostPort = null +let requestSequence = 0 +let pendingRequests = new Map() +let connectedImages = [] +let refreshPromise = null +let refreshQueued = false +let refreshAfterPendingLoad = false +let refreshAfterPendingLoadForce = false +let currentSource = { kind: "none" } +let selectedSourceNodeId = null +let currentLocalFile = null +let pendingSourceIntent = null +let pendingSourceRequest = null +let deferredContextIntent = null +let loadSequence = 0 +let stateSaveTimer = 0 +let stateSaveInFlight = false +let stateSaveDirty = false +let stateSaveFailures = 0 +let hostHydrated = false +let interactionGeneration = 0 +let toastTimer = 0 +let dragDepth = 0 +let pointer = null +let animationFrame = 0 +let lastFrameTime = 0 +let lastInteractionHintTimer = 0 +let captureInFlight = false + +const viewState = { + autoRotate: false, + fovDeg: 75, + pitchDeg: 0, + yawDeg: 0, +} + +const renderer = createPanoramaRenderer(elements.canvas, elements.viewer, scheduleRender) + +function clamp(value, minimum, maximum) { + return Math.min(maximum, Math.max(minimum, value)) +} + +function finiteNumber(value, fallback) { + return typeof value === "number" && Number.isFinite(value) ? value : fallback +} + +function normalizeYaw(value) { + let result = finiteNumber(value, 0) % 360 + if (result > 180) result -= 360 + if (result < -180) result += 360 + return result +} + +function setHidden(element, hidden) { + element.classList.toggle("is-hidden", hidden) +} + +function setLoading(active, label) { + if (label) elements.loadingText.textContent = label + setHidden(elements.loadingState, !active) +} + +function showToast(message, tone) { + window.clearTimeout(toastTimer) + elements.toast.textContent = message + elements.toast.classList.remove("is-hidden", "is-warning", "is-error") + if (tone === "warning") elements.toast.classList.add("is-warning") + if (tone === "error") elements.toast.classList.add("is-error") + toastTimer = window.setTimeout(function () { + elements.toast.classList.add("is-hidden") + }, tone === "error" ? 5200 : 3600) +} + +function setConnectionState(connected) { + elements.connectionPill.classList.toggle("is-connected", connected) + elements.connectionText.textContent = connected ? "画布已连接" : "等待宿主" +} + +function setEmptyMessage(title, description) { + elements.emptyTitle.textContent = title + elements.emptyDescription.textContent = description +} + +function setSourceStatus(label) { + elements.sourceStatus.textContent = label +} + +function updateViewControls() { + const fov = Math.round(viewState.fovDeg) + elements.fovRange.value = String(fov) + elements.fovOutput.value = String(fov) + "°" + elements.fovOutput.textContent = String(fov) + "°" + elements.autoRotateButton.classList.toggle("is-active", viewState.autoRotate) + elements.autoRotateButton.setAttribute("aria-pressed", String(viewState.autoRotate)) +} + +function updateCaptureControl() { + elements.captureButton.disabled = captureInFlight || !renderer.texture || renderer.contextState !== "ready" + elements.captureButton.setAttribute("aria-busy", String(captureInFlight)) +} + +function updateFullscreenControls() { + const active = Boolean(document.fullscreenElement) + elements.fullscreenLabel.textContent = active ? "退出全屏" : "全屏" + elements.fullscreenButton.classList.toggle("is-active", active) +} + +function updateImageMeta(width, height) { + renderer.imageWidth = width + renderer.imageHeight = height + elements.imageDimensions.textContent = String(width) + " × " + String(height) + elements.imageRatio.textContent = "比例 " + (width / height).toFixed(2) + ":1" + setHidden(elements.imageMeta, false) +} + +function clearImageMeta() { + renderer.imageWidth = 0 + renderer.imageHeight = 0 + setHidden(elements.imageMeta, true) +} + +function updateSourceSelect() { + const previous = elements.sourceSelect.value + elements.sourceSelect.replaceChildren() + connectedImages.forEach(function (image) { + const option = document.createElement("option") + option.value = image.id + option.textContent = image.name + (image.readable ? "" : "(不可读取)") + option.disabled = !image.readable + elements.sourceSelect.append(option) + }) + setHidden(elements.sourceSelectShell, connectedImages.length === 0) + const requested = currentSource.kind === "canvas" ? currentSource.nodeId : selectedSourceNodeId + const selected = connectedImages.find(function (image) { + return image.id === requested && image.readable + }) + const fallback = connectedImages.find(function (image) { + return image.id === previous && image.readable + }) + if (selected) elements.sourceSelect.value = selected.id + else if (fallback) elements.sourceSelect.value = fallback.id +} + +function snapshotState() { + return { + schemaVersion: 1, + selectedSourceNodeId: currentSource.kind === "canvas" ? currentSource.nodeId : selectedSourceNodeId, + view: { + autoRotate: viewState.autoRotate, + fovDeg: Math.round(viewState.fovDeg * 100) / 100, + pitchDeg: Math.round(viewState.pitchDeg * 100) / 100, + yawDeg: Math.round(viewState.yawDeg * 100) / 100, + }, + } +} + +function hydrateState(value) { + if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1) return + const view = value.view + if (view && typeof view === "object" && !Array.isArray(view)) { + viewState.yawDeg = normalizeYaw(finiteNumber(view.yawDeg, viewState.yawDeg)) + viewState.pitchDeg = clamp(finiteNumber(view.pitchDeg, viewState.pitchDeg), -MAX_PITCH, MAX_PITCH) + viewState.fovDeg = clamp(finiteNumber(view.fovDeg, viewState.fovDeg), MIN_FOV, MAX_FOV) + viewState.autoRotate = view.autoRotate === true + } + if (typeof value.selectedSourceNodeId === "string" && value.selectedSourceNodeId.length <= 2048) { + selectedSourceNodeId = value.selectedSourceNodeId + } + updateViewControls() + scheduleRender() +} + +function queueStateSave() { + stateSaveDirty = true + if (!hostPort || !hostHydrated || stateSaveTimer || stateSaveInFlight) return + stateSaveTimer = window.setTimeout(flushStateSave, STATE_SAVE_DELAY) +} + +async function flushStateSave() { + if (stateSaveTimer) window.clearTimeout(stateSaveTimer) + stateSaveTimer = 0 + if (!hostPort || !hostHydrated || stateSaveInFlight || !stateSaveDirty) return false + stateSaveDirty = false + stateSaveInFlight = true + try { + await hostRequest("canvas.node.updateState", { state: snapshotState() }) + stateSaveFailures = 0 + return true + } catch (error) { + stateSaveFailures += 1 + if (stateSaveFailures < STATE_SAVE_MAX_ATTEMPTS && hostPort) { + stateSaveDirty = true + stateSaveTimer = window.setTimeout( + flushStateSave, + STATE_SAVE_DELAY * Math.pow(2, stateSaveFailures), + ) + } else { + showToast(errorMessage(error, "视角状态保存失败;请重新打开插件后再试"), "error") + } + return false + } finally { + stateSaveInFlight = false + if (stateSaveDirty && hostPort && !stateSaveTimer) { + stateSaveTimer = window.setTimeout(flushStateSave, STATE_SAVE_DELAY) + } + } +} + +function markUserInteraction() { + interactionGeneration += 1 +} + +function postStateSnapshotBestEffort() { + if (!hostPort || !hostHydrated || !stateSaveDirty) return + stateSaveDirty = false + try { + hostPort.postMessage({ + id: "panorama-unload-" + String(++requestSequence), + method: "canvas.node.updateState", + params: { state: snapshotState() }, + protocol: HOST_PROTOCOL, + type: "request", + }) + } catch { + // The frame is already closing; no recovery path remains. + } +} + +function errorMessage(error, fallback) { + return error instanceof Error && error.message ? error.message : fallback +} + +function hostRequest(method, params) { + if (!hostPort) return Promise.reject(new Error("插件尚未连接到 Convax 宿主")) + const id = "panorama-" + String(++requestSequence) + return new Promise(function (resolve, reject) { + const timeout = window.setTimeout(function () { + pendingRequests.delete(id) + reject(new Error("宿主请求超时,请重试")) + }, REQUEST_TIMEOUT) + pendingRequests.set(id, { + reject: reject, + resolve: resolve, + timeout: timeout, + }) + try { + const message = { + id: id, + method: method, + protocol: HOST_PROTOCOL, + type: "request", + } + if (params !== undefined) message.params = params + hostPort.postMessage(message) + } catch (error) { + window.clearTimeout(timeout) + pendingRequests.delete(id) + reject(error) + } + }) +} + +function handleHostPortMessage(event) { + const message = event.data + if (!message || typeof message !== "object" || message.protocol !== HOST_PROTOCOL) return + if (message.type === "response" && typeof message.id === "string") { + const pending = pendingRequests.get(message.id) + if (!pending) return + pendingRequests.delete(message.id) + window.clearTimeout(pending.timeout) + if (message.ok === true) pending.resolve(message.result) + else pending.reject(new Error(typeof message.error === "string" ? message.error : "宿主请求失败")) + return + } + if (message.type !== "command" || typeof message.command !== "string") return + if (message.command === CONNECTIONS_CHANGED_COMMAND || message.command === "panorama.refresh-connections") { + void refreshConnectedImages(true) + } + if (message.command === "panorama.reset") resetView() + if (message.command === "panorama.toggle-auto-rotate") toggleAutoRotate() + if (message.command === "panorama.capture-viewport") void captureViewport() +} + +function rejectPendingRequests(reason) { + pendingRequests.forEach(function (pending) { + window.clearTimeout(pending.timeout) + pending.reject(reason) + }) + pendingRequests.clear() +} + +function handleWindowMessage(event) { + const message = event.data + if ( + hostPort + || event.source !== window.parent + || !message + || typeof message !== "object" + || message.protocol !== HOST_PROTOCOL + || message.type !== "connect" + || message.pluginId !== PLUGIN_ID + || event.ports.length !== 1 + ) return + hostPort = event.ports[0] + hostPort.onmessage = handleHostPortMessage + hostPort.start() + window.removeEventListener("message", handleWindowMessage) + setConnectionState(true) + void initializeHostContext() +} + +async function initializeHostContext() { + const hydrationGeneration = interactionGeneration + try { + const context = await hostRequest("host.context.get") + const metadata = context + && context.node + && context.node.data + && context.node.data.metadata + if (interactionGeneration === hydrationGeneration) { + hydrateState(metadata && metadata.convaxPluginState) + } + hostHydrated = true + if (stateSaveDirty) queueStateSave() + await refreshConnectedImages(true) + } catch (error) { + hostHydrated = true + showToast(errorMessage(error, "无法读取插件上下文"), "error") + } +} + +function normalizeConnectedImages(result) { + if (!result || !Array.isArray(result.images)) return [] + return result.images.filter(function (image) { + return image + && typeof image === "object" + && typeof image.id === "string" + && typeof image.name === "string" + && typeof image.readable === "boolean" + }).map(function (image) { + return { + height: typeof image.height === "number" ? image.height : undefined, + id: image.id, + mimeType: typeof image.mimeType === "string" ? image.mimeType : undefined, + name: image.name, + readable: image.readable, + width: typeof image.width === "number" ? image.width : undefined, + } + }) +} + +async function refreshConnectedImages(forceReload) { + if (!hostPort) return + if (refreshPromise) { + refreshQueued = refreshQueued || forceReload + return refreshPromise + } + refreshPromise = (async function () { + try { + const result = await hostRequest("canvas.connectedImages.list") + connectedImages = normalizeConnectedImages(result) + updateSourceSelect() + + if (pendingSourceIntent) { + refreshAfterPendingLoad = true + refreshAfterPendingLoadForce = refreshAfterPendingLoadForce || forceReload + if (currentSource.kind === "local") updateCurrentSourceStatus() + return + } + + const currentConnected = currentSource.kind === "canvas" + ? connectedImages.find(function (image) { return image.id === currentSource.nodeId && image.readable }) + : null + const preferred = connectedImages.find(function (image) { + return image.id === selectedSourceNodeId && image.readable + }) + const readable = connectedImages.filter(function (image) { return image.readable }) + const fallback = readable.length ? readable[readable.length - 1] : null + + if (currentSource.kind === "local") { + updateCurrentSourceStatus() + return + } + + if (currentSource.kind === "canvas" && !currentConnected) { + clearPanorama() + currentSource = { kind: "none" } + } + + const target = currentConnected || preferred || fallback + if (target && (forceReload || currentSource.kind !== "canvas" || currentSource.nodeId !== target.id)) { + await loadConnectedImage(target) + } else if (!target && currentSource.kind === "none") { + setSourceStatus(connectedImages.length ? "连接图片暂不可读取" : "尚未载入") + setEmptyMessage( + connectedImages.length ? "连接图片无法读取" : "连接或选择一张全景图", + connectedImages.length + ? "请连接项目内的 JPEG、PNG 或 WebP 图片,或改用本地选图。" + : "将画布中的图片连到此节点,或从本地选择 2:1 等距柱状投影图片。", + ) + } + + if (connectedImages.length > 1 && target) { + showToast("检测到 " + String(connectedImages.length) + " 张输入图片,可在底部切换。") + } + } catch (error) { + showToast(errorMessage(error, "刷新画布连接失败"), "error") + } + })() + try { + await refreshPromise + } finally { + refreshPromise = null + if (refreshQueued) { + const queuedForce = refreshQueued + refreshQueued = false + void refreshConnectedImages(queuedForce) + } + } +} + +function updateCurrentSourceStatus() { + if (currentSource.kind === "local") { + const suffix = connectedImages.length + ? " · 已连接 " + String(connectedImages.length) + " 张画布图片" + : "" + setSourceStatus("本地临时图片 · " + currentSource.name + suffix) + } else if (currentSource.kind === "canvas") { + setSourceStatus("画布 · " + currentSource.name) + } +} + +function beginSourceLoad(intent, request) { + const sequence = ++loadSequence + pendingSourceIntent = intent + pendingSourceRequest = request + return sequence +} + +function finishSourceLoad(sequence) { + if (sequence !== loadSequence) return + pendingSourceIntent = null + pendingSourceRequest = null + setLoading(false) + if (refreshAfterPendingLoad) { + const forceReload = refreshAfterPendingLoadForce + refreshAfterPendingLoad = false + refreshAfterPendingLoadForce = false + window.queueMicrotask(function () { void refreshConnectedImages(forceReload) }) + } +} + +function deferContextSource(intent) { + const accepted = !deferredContextIntent + || intent.userInitiated + || !deferredContextIntent.userInitiated + if (accepted) deferredContextIntent = intent + return accepted +} + +async function loadConnectedImage(image, options) { + if (renderer.contextState === "lost") { + const accepted = deferContextSource({ + image: image, + kind: "canvas", + userInitiated: Boolean(options && options.userInitiated), + }) + if (accepted) setSourceStatus("WebGL 恢复后载入 · " + image.name) + return false + } + const source = { + kind: "canvas", + name: image.name, + nodeId: image.id, + } + const sequence = beginSourceLoad(source, { + image: image, + kind: "canvas", + userInitiated: Boolean(options && options.userInitiated), + }) + setLoading(true, "正在读取画布图片…") + try { + const result = await hostRequest("canvas.connectedImage.read", { nodeId: image.id }) + if (sequence !== loadSequence) return false + if (!result || typeof result.dataUrl !== "string" || typeof result.mimeType !== "string") { + throw new Error("宿主没有返回可用图片") + } + const inspected = inspectDataUrlImage(result.dataUrl, result.mimeType, result.size) + source.name = typeof result.name === "string" ? result.name : image.name + await loadPanoramaSource( + new Blob([inspected.bytes], { type: inspected.mimeType }), + source, + sequence, + inspected.dimensions, + ) + if (sequence !== loadSequence) return false + currentLocalFile = null + selectedSourceNodeId = image.id + updateSourceSelect() + queueStateSave() + return true + } catch (error) { + if (sequence === loadSequence) { + const message = errorMessage(error, "画布图片载入失败") + showToast(message, "error") + updateSourceSelect() + if (!renderer.texture) { + setPersistentViewerError("无法载入连接图片", message) + } + } + if (options && options.rethrow) throw error + return false + } finally { + finishSourceLoad(sequence) + } +} + +async function loadLocalFile(file, options) { + if (!file || !ACCEPTED_IMAGE_TYPES.has(file.type)) { + showToast("请选择 JPEG、PNG 或 WebP 图片。", "error") + return false + } + if (!Number.isSafeInteger(file.size) || file.size < 1 || file.size > MAX_IMAGE_FILE_BYTES) { + showToast("本地图片必须小于或等于 16 MiB。", "error") + return false + } + if (renderer.contextState === "lost") { + const accepted = deferContextSource({ + file: file, + kind: "local", + userInitiated: Boolean(options && options.userInitiated), + }) + if (accepted) setSourceStatus("WebGL 恢复后载入 · " + file.name) + return false + } + const source = { kind: "local", name: file.name } + const sequence = beginSourceLoad(source, { + file: file, + kind: "local", + userInitiated: Boolean(options && options.userInitiated), + }) + setLoading(true, "正在解码本地图片…") + try { + const bytes = new Uint8Array(await file.arrayBuffer()) + if (sequence !== loadSequence) return false + const dimensions = inspectImageBytes(bytes, file.type) + await loadPanoramaSource(file, source, sequence, dimensions) + if (sequence !== loadSequence) return false + currentLocalFile = file + updateSourceSelect() + queueStateSave() + return true + } catch (error) { + if (sequence === loadSequence) { + const message = errorMessage(error, "本地图片载入失败") + showToast(message, "error") + updateSourceSelect() + if (!renderer.texture) setPersistentViewerError("无法载入本地图片", message) + } + if (options && options.rethrow) throw error + return false + } finally { + finishSourceLoad(sequence) + } +} + +async function loadPanoramaSource(blob, source, sequence, dimensions) { + const decoded = await decodePanoramaImage(blob, dimensions, renderer.gl) + if (sequence !== loadSequence) { + decoded.bitmap.close() + return + } + try { + renderer.uploadTexture(decoded.bitmap) + } finally { + decoded.bitmap.close() + } + renderer.contextState = "ready" + updateCaptureControl() + currentSource = source + updateCurrentSourceStatus() + setEmptyMessage("连接或选择一张全景图", "将画布中的图片连到此节点,或从本地选择 2:1 等距柱状投影图片。") + updateImageMeta(dimensions.width, dimensions.height) + elements.viewer.classList.add("has-image") + setHidden(elements.interactionHint, false) + window.clearTimeout(lastInteractionHintTimer) + lastInteractionHintTimer = window.setTimeout(function () { + setHidden(elements.interactionHint, true) + }, 3200) + if (decoded.target.width !== dimensions.width || decoded.target.height !== dimensions.height) { + showToast("图片已按 GPU 预算缩放到 " + String(decoded.target.width) + " × " + String(decoded.target.height) + "。", "warning") + } else if (Math.abs(dimensions.ratio - 2) > 0.04) { + showToast("图片比例为 " + dimensions.ratio.toFixed(2) + ":1,预览可能出现轻微拉伸。", "warning") + } + scheduleRender() +} + +function setPersistentViewerError(title, description) { + renderer.contextState = "failed" + updateCaptureControl() + elements.viewer.classList.remove("has-image") + setHidden(elements.interactionHint, true) + clearImageMeta() + setEmptyMessage(title, description) + setSourceStatus("载入失败") +} + +function clearPanorama() { + loadSequence += 1 + renderer.clearTexture() + updateCaptureControl() + elements.viewer.classList.remove("has-image") + setHidden(elements.interactionHint, true) + clearImageMeta() +} + +function blobDataUrl(blob) { + return new Promise(function (resolve, reject) { + const reader = new FileReader() + reader.addEventListener("load", function () { + if (typeof reader.result === "string") resolve(reader.result) + else reject(new Error("截图数据读取失败")) + }, { once: true }) + reader.addEventListener("error", function () { + reject(reader.error || new Error("截图数据读取失败")) + }, { once: true }) + reader.readAsDataURL(blob) + }) +} + +async function captureViewport() { + if (captureInFlight) return + if (!renderer.texture || renderer.contextState !== "ready") { + showToast("请先载入全景图后再截取画面。", "warning") + return + } + captureInFlight = true + updateCaptureControl() + setLoading(true, "正在截取当前画面…") + try { + const blob = await renderer.capture(viewState) + const dataUrl = await blobDataUrl(blob) + await hostRequest("canvas.image.create", { + dataUrl: dataUrl, + name: "全景视口截图.png", + }) + showToast("已在画布中创建当前视口截图。") + } catch (error) { + showToast(errorMessage(error, "当前画面截取失败"), "error") + } finally { + captureInFlight = false + updateCaptureControl() + setLoading(false) + } +} + +function scheduleRender() { + if (animationFrame) return + animationFrame = window.requestAnimationFrame(frame) +} + +function frame(timestamp) { + animationFrame = 0 + const delta = lastFrameTime ? Math.min(50, timestamp - lastFrameTime) : 0 + lastFrameTime = timestamp + if ( + viewState.autoRotate + && !pointer + && renderer.texture + && document.visibilityState === "visible" + ) { + viewState.yawDeg = normalizeYaw( + viewState.yawDeg + AUTO_ROTATE_DEGREES_PER_SECOND * delta / 1000, + ) + } + renderer.render(viewState) + if (viewState.autoRotate && renderer.texture && document.visibilityState === "visible") { + scheduleRender() + } +} + +function resetView() { + markUserInteraction() + viewState.yawDeg = 0 + viewState.pitchDeg = 0 + viewState.fovDeg = 75 + updateViewControls() + queueStateSave() + scheduleRender() +} + +function toggleAutoRotate() { + markUserInteraction() + viewState.autoRotate = !viewState.autoRotate + updateViewControls() + queueStateSave() + lastFrameTime = 0 + scheduleRender() +} + +function handlePointerDown(event) { + if (!renderer.texture || event.button !== 0) return + markUserInteraction() + pointer = { + id: event.pointerId, + x: event.clientX, + y: event.clientY, + } + elements.viewer.classList.add("is-dragging") + elements.viewer.setPointerCapture(event.pointerId) + setHidden(elements.interactionHint, true) +} + +function handlePointerMove(event) { + if (!pointer || pointer.id !== event.pointerId) return + const deltaX = event.clientX - pointer.x + const deltaY = event.clientY - pointer.y + pointer.x = event.clientX + pointer.y = event.clientY + const sensitivity = viewState.fovDeg / 75 + viewState.yawDeg = normalizeYaw(viewState.yawDeg - deltaX * 0.16 * sensitivity) + viewState.pitchDeg = clamp(viewState.pitchDeg + deltaY * 0.14 * sensitivity, -MAX_PITCH, MAX_PITCH) + scheduleRender() +} + +function handlePointerEnd(event) { + if (!pointer || pointer.id !== event.pointerId) return + pointer = null + elements.viewer.classList.remove("is-dragging") + if (elements.viewer.hasPointerCapture(event.pointerId)) elements.viewer.releasePointerCapture(event.pointerId) + queueStateSave() + void flushStateSave() + lastFrameTime = 0 + scheduleRender() +} + +function cancelPointerInteraction() { + if (!pointer) return + pointer = null + elements.viewer.classList.remove("is-dragging") + queueStateSave() + void flushStateSave() + lastFrameTime = 0 + scheduleRender() +} + +function handleWheel(event) { + if (!renderer.texture) return + event.preventDefault() + markUserInteraction() + const unit = event.deltaMode === WheelEvent.DOM_DELTA_LINE + ? 16 + : event.deltaMode === WheelEvent.DOM_DELTA_PAGE + ? elements.viewer.clientHeight + : 1 + const delta = clamp(event.deltaY * unit * 0.03, -6, 6) + viewState.fovDeg = clamp(viewState.fovDeg + delta, MIN_FOV, MAX_FOV) + updateViewControls() + queueStateSave() + scheduleRender() +} + +function openFilePicker() { + elements.fileInput.click() +} + +async function toggleFullscreen() { + try { + if (document.fullscreenElement) await document.exitFullscreen() + else await document.documentElement.requestFullscreen() + } catch (error) { + showToast(errorMessage(error, "宿主或系统未允许全屏预览"), "error") + } +} + +function handleDragEnter(event) { + event.preventDefault() + dragDepth += 1 + setHidden(elements.dragHint, false) +} + +function handleDragLeave(event) { + event.preventDefault() + dragDepth = Math.max(0, dragDepth - 1) + if (!dragDepth) setHidden(elements.dragHint, true) +} + +function handleDrop(event) { + event.preventDefault() + dragDepth = 0 + setHidden(elements.dragHint, true) + const files = Array.from(event.dataTransfer.files || []) + const image = files.find(function (file) { return ACCEPTED_IMAGE_TYPES.has(file.type) }) + if (!image) { + showToast("拖入内容中没有可用的 JPEG、PNG 或 WebP 图片。", "error") + return + } + markUserInteraction() + void loadLocalFile(image, { userInitiated: true }) +} + +async function restoreRenderer() { + renderer.contextState = "restoring" + setLoading(true, "正在恢复 WebGL 预览…") + setSourceStatus("渲染器恢复中") + try { + renderer.initialize() + const deferred = deferredContextIntent + deferredContextIntent = null + if (deferred?.kind === "canvas") { + await loadConnectedImage(deferred.image, { + rethrow: true, + userInitiated: deferred.userInitiated, + }) + } else if (deferred?.kind === "local") { + await loadLocalFile(deferred.file, { + rethrow: true, + userInitiated: deferred.userInitiated, + }) + } else if (currentSource.kind === "canvas") { + const image = connectedImages.find(function (candidate) { + return candidate.id === currentSource.nodeId && candidate.readable + }) + if (!image) throw new Error("原画布图片已断开,无法恢复预览") + await loadConnectedImage(image, { rethrow: true }) + } else if (currentSource.kind === "local" && currentLocalFile) { + await loadLocalFile(currentLocalFile, { rethrow: true }) + } else { + renderer.contextState = "ready" + setLoading(false) + setSourceStatus("尚未载入") + setEmptyMessage( + "连接或选择一张全景图", + "将画布中的图片连到此节点,或从本地选择 2:1 等距柱状投影图片。", + ) + } + } catch (error) { + const message = errorMessage(error, "WebGL 恢复失败") + setLoading(false) + setPersistentViewerError("WebGL 预览恢复失败", message) + showToast(message, "error") + } +} + +function bindEvents() { + window.addEventListener("message", handleWindowMessage) + window.addEventListener("beforeunload", function () { + postStateSnapshotBestEffort() + window.clearTimeout(stateSaveTimer) + window.clearTimeout(toastTimer) + window.clearTimeout(lastInteractionHintTimer) + if (animationFrame) window.cancelAnimationFrame(animationFrame) + rejectPendingRequests(new Error("插件页面已关闭")) + if (hostPort) { + hostPort.onmessage = null + hostPort.close() + hostPort = null + } + renderer.clearTexture() + }) + document.addEventListener("fullscreenchange", updateFullscreenControls) + document.addEventListener("visibilitychange", function () { + lastFrameTime = 0 + if (document.visibilityState === "visible") scheduleRender() + else void flushStateSave() + }) + window.addEventListener("blur", cancelPointerInteraction) + elements.emptyChooseButton.addEventListener("click", openFilePicker) + elements.chooseButton.addEventListener("click", openFilePicker) + elements.fileInput.addEventListener("change", function () { + const file = elements.fileInput.files && elements.fileInput.files[0] + elements.fileInput.value = "" + if (file) { + markUserInteraction() + void loadLocalFile(file, { userInitiated: true }) + } + }) + elements.refreshButton.addEventListener("click", function () { + void refreshConnectedImages(true) + }) + elements.captureButton.addEventListener("click", function () { + void captureViewport() + }) + elements.resetButton.addEventListener("click", resetView) + elements.autoRotateButton.addEventListener("click", toggleAutoRotate) + elements.fullscreenButton.addEventListener("click", function () { + void toggleFullscreen() + }) + elements.fovRange.addEventListener("input", function () { + markUserInteraction() + viewState.fovDeg = clamp(Number(elements.fovRange.value), MIN_FOV, MAX_FOV) + updateViewControls() + queueStateSave() + scheduleRender() + }) + elements.fovRange.addEventListener("change", function () { void flushStateSave() }) + elements.sourceSelect.addEventListener("change", function () { + const image = connectedImages.find(function (candidate) { + return candidate.id === elements.sourceSelect.value && candidate.readable + }) + if (image) { + markUserInteraction() + void loadConnectedImage(image, { userInitiated: true }) + } + }) + elements.viewer.addEventListener("pointerdown", handlePointerDown) + elements.viewer.addEventListener("pointermove", handlePointerMove) + elements.viewer.addEventListener("pointerup", handlePointerEnd) + elements.viewer.addEventListener("pointercancel", handlePointerEnd) + elements.viewer.addEventListener("lostpointercapture", cancelPointerInteraction) + elements.viewer.addEventListener("wheel", handleWheel, { passive: false }) + elements.viewer.addEventListener("dragenter", handleDragEnter) + elements.viewer.addEventListener("dragover", function (event) { event.preventDefault() }) + elements.viewer.addEventListener("dragleave", handleDragLeave) + elements.viewer.addEventListener("drop", handleDrop) + elements.canvas.addEventListener("webglcontextlost", function (event) { + event.preventDefault() + if (pendingSourceRequest) deferContextSource(pendingSourceRequest) + loadSequence += 1 + pendingSourceIntent = null + pendingSourceRequest = null + refreshAfterPendingLoad = false + refreshAfterPendingLoadForce = false + renderer.contextState = "lost" + renderer.ready = false + renderer.texture = null + updateCaptureControl() + elements.viewer.classList.remove("has-image") + setHidden(elements.interactionHint, true) + clearImageMeta() + setEmptyMessage("WebGL 上下文已丢失", "正在等待图形系统恢复;恢复成功后会自动重新载入当前全景图。") + setSourceStatus("渲染器恢复中") + setLoading(true, "等待 WebGL 恢复…") + showToast("WebGL 上下文已丢失,正在等待恢复。", "error") + }) + elements.canvas.addEventListener("webglcontextrestored", function () { + void restoreRenderer() + }) + const resizeObserver = new ResizeObserver(renderer.resize) + resizeObserver.observe(elements.viewer) +} + +function boot() { + bindEvents() + updateViewControls() + updateFullscreenControls() + setConnectionState(false) + setSourceStatus("尚未载入") + try { + renderer.initialize() + renderer.render(viewState) + } catch (error) { + setEmptyMessage("WebGL2 不可用", errorMessage(error, "无法初始化全景渲染器")) + elements.chooseButton.disabled = true + elements.emptyChooseButton.disabled = true + showToast(errorMessage(error, "无法初始化 WebGL2"), "error") + } +} + +boot() diff --git a/packages/plugins/panorama-viewer/package/assets/panorama-image.js b/packages/plugins/panorama-viewer/package/assets/panorama-image.js new file mode 100644 index 0000000..076f248 --- /dev/null +++ b/packages/plugins/panorama-viewer/package/assets/panorama-image.js @@ -0,0 +1,171 @@ +export const MAX_IMAGE_FILE_BYTES = 16 * 1024 * 1024 +export const ACCEPTED_IMAGE_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]) + +const MAX_IMAGE_PIXELS = 40 * 1024 * 1024 +const MAX_IMAGE_DIMENSION = 16384 +const MAX_TEXTURE_PIXELS = 24 * 1024 * 1024 +const MAX_TEXTURE_DIMENSION = 8192 + +function errorMessage(error, fallback) { + return error instanceof Error && error.message ? error.message : fallback +} + +function readPngDimensions(bytes) { + const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] + if (bytes.length < 24 || !signature.every(function (value, index) { return bytes[index] === value })) { + throw new Error("PNG 文件头无效") + } + if (String.fromCharCode.apply(null, bytes.subarray(12, 16)) !== "IHDR") { + throw new Error("PNG 缺少 IHDR 尺寸信息") + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + return { height: view.getUint32(20), width: view.getUint32(16) } +} + +function readJpegDimensions(bytes) { + if (bytes.length < 4 || bytes[0] !== 0xff || bytes[1] !== 0xd8) throw new Error("JPEG 文件头无效") + const startOfFrameMarkers = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, + 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, + ]) + let offset = 2 + while (offset + 3 < bytes.length) { + while (offset < bytes.length && bytes[offset] !== 0xff) offset += 1 + while (offset < bytes.length && bytes[offset] === 0xff) offset += 1 + if (offset >= bytes.length) break + const marker = bytes[offset] + offset += 1 + if (marker === 0xd9 || marker === 0xda) break + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue + if (offset + 1 >= bytes.length) break + const length = bytes[offset] * 256 + bytes[offset + 1] + if (length < 2 || offset + length > bytes.length) throw new Error("JPEG 数据段无效") + if (startOfFrameMarkers.has(marker) && length >= 7) { + return { + height: bytes[offset + 3] * 256 + bytes[offset + 4], + width: bytes[offset + 5] * 256 + bytes[offset + 6], + } + } + offset += length + } + throw new Error("JPEG 中没有可用的尺寸信息") +} + +function readWebpDimensions(bytes) { + const ascii = function (start, end) { + return String.fromCharCode.apply(null, bytes.subarray(start, end)) + } + if (bytes.length < 25 || ascii(0, 4) !== "RIFF" || ascii(8, 12) !== "WEBP") { + throw new Error("WebP 文件头无效") + } + const chunk = ascii(12, 16) + if (chunk === "VP8X" && bytes.length >= 30) { + return { + height: 1 + bytes[27] + bytes[28] * 256 + bytes[29] * 65536, + width: 1 + bytes[24] + bytes[25] * 256 + bytes[26] * 65536, + } + } + if (chunk === "VP8L" && bytes.length >= 25 && bytes[20] === 0x2f) { + return { + height: 1 + ((bytes[22] >> 6) | (bytes[23] << 2) | ((bytes[24] & 0x0f) << 10)), + width: 1 + bytes[21] + ((bytes[22] & 0x3f) << 8), + } + } + if (chunk === "VP8 " && bytes.length >= 30 + && bytes[23] === 0x9d && bytes[24] === 0x01 && bytes[25] === 0x2a) { + return { + height: (bytes[28] | (bytes[29] << 8)) & 0x3fff, + width: (bytes[26] | (bytes[27] << 8)) & 0x3fff, + } + } + throw new Error("WebP 中没有可用的尺寸信息") +} + +function validatePanoramaDimensions(width, height) { + if (!Number.isSafeInteger(width) || !Number.isSafeInteger(height) || width < 1 || height < 1) { + throw new Error("图片尺寸无效") + } + if (width > MAX_IMAGE_DIMENSION || height > MAX_IMAGE_DIMENSION || width * height > MAX_IMAGE_PIXELS) { + throw new Error("图片像素尺寸过大;最大支持约 40 MP、单边不超过 16384 像素") + } + const ratio = width / height + if (ratio < 1.8 || ratio > 2.2) { + throw new Error("需要约 2:1 的等距柱状投影全景图;不支持 cubemap 拆分或拼接") + } + return { height: height, ratio: ratio, width: width } +} + +export function inspectImageBytes(bytes, mimeType) { + const normalizedMimeType = String(mimeType).toLowerCase() + let dimensions + if (normalizedMimeType === "image/png") dimensions = readPngDimensions(bytes) + else if (normalizedMimeType === "image/jpeg") dimensions = readJpegDimensions(bytes) + else if (normalizedMimeType === "image/webp") dimensions = readWebpDimensions(bytes) + else throw new Error("图片格式不受支持") + return validatePanoramaDimensions(dimensions.width, dimensions.height) +} + +export function inspectDataUrlImage(value, mimeType, expectedSize) { + const normalizedMimeType = String(mimeType).toLowerCase() + if (!ACCEPTED_IMAGE_TYPES.has(normalizedMimeType) || typeof value !== "string") { + throw new Error("宿主返回了不受支持的图片格式") + } + const prefix = "data:" + normalizedMimeType + ";base64," + if (value.slice(0, prefix.length).toLowerCase() !== prefix) { + throw new Error("宿主返回的图片数据无效") + } + const encoded = value.slice(prefix.length) + if (!encoded || encoded.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(encoded)) { + throw new Error("宿主返回的图片不是有效 base64 数据") + } + if (encoded.length > Math.ceil(MAX_IMAGE_FILE_BYTES / 3) * 4 + 4) { + throw new Error("连接图片超过 16 MiB 限制") + } + let binary + try { + binary = window.atob(encoded) + } catch { + throw new Error("宿主返回的图片不是有效 base64 数据") + } + if (binary.length > MAX_IMAGE_FILE_BYTES + || (typeof expectedSize === "number" && expectedSize !== binary.length)) { + throw new Error("连接图片大小与宿主声明不一致") + } + const bytes = new Uint8Array(binary.length) + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index) + return { + bytes: bytes, + dimensions: inspectImageBytes(bytes, normalizedMimeType), + mimeType: normalizedMimeType, + } +} + +function textureTargetDimensions(dimensions, gl) { + if (!gl) throw new Error("WebGL2 尚未就绪") + const dimensionLimit = Math.min(MAX_TEXTURE_DIMENSION, gl.getParameter(gl.MAX_TEXTURE_SIZE)) + const dimensionScale = Math.min(dimensionLimit / dimensions.width, dimensionLimit / dimensions.height, 1) + const pixelScale = Math.min(Math.sqrt(MAX_TEXTURE_PIXELS / (dimensions.width * dimensions.height)), 1) + const scale = Math.min(dimensionScale, pixelScale) + return { + height: Math.max(1, Math.floor(dimensions.height * scale)), + width: Math.max(1, Math.floor(dimensions.width * scale)), + } +} + +export async function decodePanoramaImage(blob, dimensions, gl) { + const target = textureTargetDimensions(dimensions, gl) + try { + const bitmap = await createImageBitmap(blob, { + resizeHeight: target.height, + resizeQuality: "high", + resizeWidth: target.width, + }) + if (bitmap.width !== target.width || bitmap.height !== target.height) { + bitmap.close() + throw new Error("图片解码尺寸与预期不一致") + } + return { bitmap: bitmap, target: target } + } catch (error) { + throw new Error(errorMessage(error, "图片无法解码或格式不受支持")) + } +} diff --git a/packages/plugins/panorama-viewer/package/assets/panorama-renderer.js b/packages/plugins/panorama-viewer/package/assets/panorama-renderer.js new file mode 100644 index 0000000..296d313 --- /dev/null +++ b/packages/plugins/panorama-viewer/package/assets/panorama-renderer.js @@ -0,0 +1,217 @@ +function degreesToRadians(value) { + return value * Math.PI / 180 +} + +function compileShader(gl, type, source) { + const shader = gl.createShader(type) + if (!shader) throw new Error("无法创建 WebGL shader") + gl.shaderSource(shader, source) + gl.compileShader(shader) + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + const message = gl.getShaderInfoLog(shader) || "WebGL shader 编译失败" + gl.deleteShader(shader) + throw new Error(message) + } + return shader +} + +function createProgram(gl) { + const vertexSource = [ + "#version 300 es", + "out vec2 vUv;", + "void main() {", + " vec2 position = vec2(float((gl_VertexID << 1) & 2), float(gl_VertexID & 2));", + " vUv = position;", + " gl_Position = vec4(position * 2.0 - 1.0, 0.0, 1.0);", + "}", + ].join("\n") + const fragmentSource = [ + "#version 300 es", + "precision highp float;", + "in vec2 vUv;", + "uniform sampler2D uPanorama;", + "uniform vec2 uViewport;", + "uniform float uYaw;", + "uniform float uPitch;", + "uniform float uFov;", + "out vec4 outColor;", + "const float PI = 3.141592653589793;", + "void main() {", + " vec2 ndc = vUv * 2.0 - 1.0;", + " float aspect = uViewport.x / max(uViewport.y, 1.0);", + " float scale = tan(uFov * 0.5);", + " vec3 forward = vec3(sin(uYaw) * cos(uPitch), sin(uPitch), -cos(uYaw) * cos(uPitch));", + " vec3 right = normalize(cross(forward, vec3(0.0, 1.0, 0.0)));", + " vec3 up = normalize(cross(right, forward));", + " vec3 ray = normalize(forward + right * ndc.x * aspect * scale + up * ndc.y * scale);", + " float longitude = atan(ray.x, -ray.z);", + " float latitude = asin(clamp(ray.y, -1.0, 1.0));", + " vec2 panoramaUv = vec2(fract(0.5 + longitude / (2.0 * PI)), 0.5 - latitude / PI);", + " vec3 color = texture(uPanorama, panoramaUv).rgb;", + " outColor = vec4(color, 1.0);", + "}", + ].join("\n") + const vertexShader = compileShader(gl, gl.VERTEX_SHADER, vertexSource) + const fragmentShader = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSource) + const program = gl.createProgram() + if (!program) throw new Error("无法创建 WebGL program") + gl.attachShader(program, vertexShader) + gl.attachShader(program, fragmentShader) + gl.linkProgram(program) + gl.deleteShader(vertexShader) + gl.deleteShader(fragmentShader) + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + const message = gl.getProgramInfoLog(program) || "WebGL program 链接失败" + gl.deleteProgram(program) + throw new Error(message) + } + return program +} + +export function createPanoramaRenderer(canvas, viewer, scheduleRender) { + const renderer = { + contextState: "initializing", + gl: null, + imageHeight: 0, + imageWidth: 0, + program: null, + ready: false, + texture: null, + uniforms: null, + } + + renderer.initialize = function () { + const gl = canvas.getContext("webgl2", { + alpha: false, + antialias: true, + depth: false, + powerPreference: "high-performance", + preserveDrawingBuffer: false, + }) + if (!gl) throw new Error("当前环境不支持 WebGL2") + const program = createProgram(gl) + renderer.gl = gl + renderer.program = program + renderer.uniforms = { + fov: gl.getUniformLocation(program, "uFov"), + panorama: gl.getUniformLocation(program, "uPanorama"), + pitch: gl.getUniformLocation(program, "uPitch"), + viewport: gl.getUniformLocation(program, "uViewport"), + yaw: gl.getUniformLocation(program, "uYaw"), + } + gl.useProgram(program) + gl.uniform1i(renderer.uniforms.panorama, 0) + renderer.contextState = "ready" + renderer.resize() + } + + renderer.uploadTexture = function (image) { + const gl = renderer.gl + if (!gl || !renderer.program) throw new Error("WebGL2 尚未就绪") + for (let count = 0; count < 8 && gl.getError() !== gl.NO_ERROR; count += 1) { + // Drain stale WebGL errors so this upload owns the next error result. + } + const texture = gl.createTexture() + if (!texture) throw new Error("无法创建全景纹理") + try { + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, texture) + gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.REPEAT) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR) + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR) + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image) + gl.generateMipmap(gl.TEXTURE_2D) + const uploadError = gl.getError() + if (uploadError !== gl.NO_ERROR) throw new Error("GPU 纹理上传失败(WebGL " + String(uploadError) + ")") + if (renderer.texture) gl.deleteTexture(renderer.texture) + renderer.texture = texture + renderer.ready = true + } catch (error) { + gl.deleteTexture(texture) + throw error + } + } + + renderer.clearTexture = function () { + if (renderer.gl && renderer.texture) renderer.gl.deleteTexture(renderer.texture) + renderer.texture = null + renderer.ready = false + } + + renderer.resize = function () { + const gl = renderer.gl + if (!gl) return + const ratio = Math.min(window.devicePixelRatio || 1, 2) + const width = Math.max(1, Math.round(viewer.clientWidth * ratio)) + const height = Math.max(1, Math.round(viewer.clientHeight * ratio)) + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width + canvas.height = height + } + gl.viewport(0, 0, width, height) + scheduleRender() + } + + renderer.render = function (viewState) { + const gl = renderer.gl + if (!gl || !renderer.program) return + gl.viewport(0, 0, canvas.width, canvas.height) + gl.clearColor(0.04, 0.055, 0.08, 1) + gl.clear(gl.COLOR_BUFFER_BIT) + if (!renderer.texture || !renderer.ready) return + gl.useProgram(renderer.program) + gl.activeTexture(gl.TEXTURE0) + gl.bindTexture(gl.TEXTURE_2D, renderer.texture) + gl.uniform2f(renderer.uniforms.viewport, canvas.width, canvas.height) + gl.uniform1f(renderer.uniforms.yaw, degreesToRadians(viewState.yawDeg)) + gl.uniform1f(renderer.uniforms.pitch, degreesToRadians(viewState.pitchDeg)) + gl.uniform1f(renderer.uniforms.fov, degreesToRadians(viewState.fovDeg)) + gl.drawArrays(gl.TRIANGLES, 0, 3) + } + + renderer.capture = function (viewState) { + const gl = renderer.gl + if (!gl || !renderer.program || !renderer.texture || !renderer.ready) { + return Promise.reject(new Error("请先载入全景图后再截取画面")) + } + renderer.render(viewState) + const width = canvas.width + const height = canvas.height + const pixels = new Uint8Array(width * height * 4) + gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, pixels) + const readError = gl.getError() + if (readError !== gl.NO_ERROR) { + return Promise.reject(new Error("当前画面读取失败(WebGL " + String(readError) + ")")) + } + + const output = document.createElement("canvas") + output.width = width + output.height = height + const context = output.getContext("2d", { alpha: false }) + if (!context) return Promise.reject(new Error("无法创建截图画布")) + const flipped = new Uint8ClampedArray(pixels.length) + const rowBytes = width * 4 + for (let sourceRow = 0; sourceRow < height; sourceRow += 1) { + const targetRow = height - sourceRow - 1 + flipped.set(pixels.subarray(sourceRow * rowBytes, (sourceRow + 1) * rowBytes), targetRow * rowBytes) + } + context.putImageData(new ImageData(flipped, width, height), 0, 0) + return new Promise(function (resolve, reject) { + output.toBlob(function (blob) { + if (blob) resolve(blob) + else reject(new Error("当前画面编码失败")) + }, "image/png") + }) + } + + renderer.dispose = function () { + renderer.clearTexture() + if (renderer.gl && renderer.program) renderer.gl.deleteProgram(renderer.program) + renderer.program = null + renderer.uniforms = null + } + + return renderer +} diff --git a/packages/plugins/panorama-viewer/package/assets/styles.css b/packages/plugins/panorama-viewer/package/assets/styles.css new file mode 100644 index 0000000..6266b29 --- /dev/null +++ b/packages/plugins/panorama-viewer/package/assets/styles.css @@ -0,0 +1,765 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #090c11; + color: #f4f6f8; + font-synthesis: none; +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + height: 100%; + margin: 0; + overflow: hidden; + background: #090c11; +} + +button, +input, +select { + font: inherit; +} + +button, +select, +input[type="range"] { + -webkit-app-region: no-drag; +} + +button { + color: inherit; +} + +#fileInput { + position: fixed; + width: 1px; + height: 1px; + pointer-events: none; + opacity: 0; +} + +svg { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; +} + +.app-shell { + display: grid; + width: 100%; + height: 100%; + grid-template-rows: 64px minmax(0, 1fr) auto; + background: + radial-gradient(circle at 16% -10%, rgba(76, 137, 255, 0.12), transparent 30%), + #090c11; +} + +.topbar { + position: relative; + z-index: 4; + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 0 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(11, 14, 20, 0.94); + backdrop-filter: blur(18px); +} + +.brand, +.topbar-actions, +.source-actions, +.view-controls, +.source-section { + display: flex; + align-items: center; +} + +.brand { + min-width: 0; + gap: 11px; +} + +.brand-mark { + display: grid; + width: 36px; + height: 36px; + flex: 0 0 auto; + place-items: center; + border: 1px solid rgba(129, 176, 255, 0.25); + border-radius: 11px; + background: linear-gradient(145deg, rgba(75, 133, 255, 0.22), rgba(45, 203, 186, 0.08)); + color: #9bbcff; + box-shadow: inset 0 1px rgba(255, 255, 255, 0.08); +} + +.brand-mark svg { + width: 24px; + height: 24px; + stroke-width: 1.35; +} + +.brand-title { + overflow: hidden; + color: #f7f9fb; + font-size: 14px; + font-weight: 650; + letter-spacing: 0.01em; + text-overflow: ellipsis; + white-space: nowrap; +} + +.brand-subtitle { + margin-top: 2px; + color: #747d8c; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.topbar-actions { + gap: 8px; +} + +.connection-pill { + display: inline-flex; + height: 28px; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(255, 255, 255, 0.035); + color: #8a93a2; + font-size: 10px; + font-weight: 550; +} + +.connection-dot { + width: 6px; + height: 6px; + border-radius: 999px; + background: #667080; +} + +.connection-pill.is-connected { + border-color: rgba(76, 211, 156, 0.18); + color: #8fdab9; +} + +.connection-pill.is-connected .connection-dot { + background: #4cd39c; + box-shadow: 0 0 0 3px rgba(76, 211, 156, 0.11); +} + +.icon-button, +.control-button, +.secondary-button, +.primary-button { + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + outline: none; + cursor: pointer; +} + +.icon-button { + width: 30px; + height: 30px; + border-radius: 9px; + background: transparent; + color: #7d8797; +} + +.icon-button:hover, +.icon-button:focus-visible { + background: rgba(255, 255, 255, 0.07); + color: #e6e9ee; +} + +.icon-button svg { + width: 16px; + height: 16px; +} + +.viewer { + position: relative; + min-height: 0; + overflow: hidden; + background: + linear-gradient(rgba(255, 255, 255, 0.023) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.023) 1px, transparent 1px), + radial-gradient(circle at 50% 42%, #192334 0%, #0e131c 42%, #090c11 100%); + background-size: 40px 40px, 40px 40px, auto; + cursor: default; + touch-action: none; +} + +.viewer.has-image { + cursor: grab; +} + +.viewer.is-dragging { + cursor: grabbing; +} + +#panoramaCanvas, +.viewer-vignette { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +#panoramaCanvas { + display: block; + opacity: 0; + transition: opacity 260ms ease; +} + +.viewer.has-image #panoramaCanvas { + opacity: 1; +} + +.viewer-vignette { + pointer-events: none; + opacity: 0; + background: radial-gradient(circle, transparent 58%, rgba(0, 0, 0, 0.36) 100%); + transition: opacity 260ms ease; +} + +.viewer.has-image .viewer-vignette { + opacity: 1; +} + +.empty-state { + position: absolute; + inset: 0; + display: flex; + max-width: 560px; + align-items: center; + justify-content: center; + margin: auto; + flex-direction: column; + padding: 28px; + text-align: center; + transition: opacity 180ms ease, transform 180ms ease; +} + +.viewer.has-image .empty-state { + pointer-events: none; + opacity: 0; + transform: scale(0.98); +} + +.empty-orbit { + position: relative; + width: 126px; + height: 126px; + margin-bottom: 15px; +} + +.empty-globe { + position: absolute; + inset: 18px; + width: 90px; + height: 90px; + color: #87aef9; + filter: drop-shadow(0 0 18px rgba(82, 132, 235, 0.22)); + stroke-width: 1.15; +} + +.orbit-ring { + position: absolute; + inset: 46px 5px; + border: 1px solid rgba(116, 170, 255, 0.36); + border-radius: 50%; + transform: rotate(-8deg); +} + +.orbit-ring-one { + box-shadow: 0 0 22px rgba(76, 136, 255, 0.12); +} + +.orbit-ring-two { + inset: 52px -4px; + border-color: rgba(76, 211, 190, 0.2); + transform: rotate(10deg); +} + +.empty-eyebrow { + margin-bottom: 8px; + color: #7f9ed9; + font-size: 9px; + font-weight: 700; + letter-spacing: 0.2em; +} + +.empty-state h1 { + margin: 0; + color: #f0f3f7; + font-size: clamp(20px, 2.5vw, 26px); + font-weight: 620; + letter-spacing: -0.02em; +} + +.empty-state p { + max-width: 450px; + margin: 10px 0 0; + color: #808a9a; + font-size: 12px; + line-height: 1.7; +} + +.empty-actions { + margin-top: 19px; +} + +.primary-button { + height: 38px; + gap: 8px; + padding: 0 16px; + border-radius: 10px; + background: #eaf0ff; + color: #10141b; + font-size: 12px; + font-weight: 650; + box-shadow: 0 8px 30px rgba(0, 0, 0, 0.22); +} + +.primary-button:hover, +.primary-button:focus-visible { + background: #ffffff; + box-shadow: 0 8px 34px rgba(68, 119, 222, 0.18); +} + +.primary-button svg, +.secondary-button svg, +.control-button svg { + width: 15px; + height: 15px; +} + +.feature-tags { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 6px; + margin-top: 18px; +} + +.feature-tags span { + padding: 4px 8px; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 999px; + background: rgba(255, 255, 255, 0.03); + color: #697383; + font-size: 9px; +} + +.loading-state, +.drag-hint { + position: absolute; + z-index: 3; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + background: rgba(8, 11, 16, 0.72); + color: #d9dfe8; + font-size: 12px; + backdrop-filter: blur(5px); +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255, 255, 255, 0.16); + border-top-color: #91b4ff; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +.drag-hint { + border: 2px dashed rgba(133, 176, 255, 0.45); + background: rgba(20, 38, 69, 0.82); + color: #d9e5ff; + font-weight: 600; +} + +.drag-hint svg { + width: 22px; + height: 22px; +} + +.interaction-hint { + position: absolute; + z-index: 2; + top: 14px; + left: 50%; + display: flex; + height: 26px; + align-items: center; + gap: 8px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 999px; + background: rgba(7, 9, 13, 0.54); + color: rgba(255, 255, 255, 0.66); + font-size: 9px; + transform: translateX(-50%); + backdrop-filter: blur(8px); +} + +.hint-divider, +.meta-divider { + width: 1px; + height: 10px; + background: rgba(255, 255, 255, 0.16); +} + +.control-deck { + position: relative; + z-index: 4; + display: grid; + min-height: 82px; + align-items: center; + gap: 12px 20px; + grid-template-columns: minmax(220px, 1fr) auto; + padding: 12px 16px; + border-top: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(11, 14, 20, 0.98); +} + +.source-section { + min-width: 0; + justify-content: space-between; + gap: 12px; +} + +.source-copy { + min-width: 0; +} + +.section-label { + display: block; + margin-bottom: 3px; + color: #606a79; + font-size: 9px; + font-weight: 650; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.source-status { + display: block; + max-width: 250px; + overflow: hidden; + color: #cbd1da; + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.source-actions { + min-width: 0; + gap: 7px; +} + +.secondary-button, +.control-button { + height: 34px; + gap: 7px; + padding: 0 11px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 9px; + background: rgba(255, 255, 255, 0.035); + color: #b9c0cb; + font-size: 10px; + font-weight: 550; +} + +.secondary-button:hover, +.secondary-button:focus-visible, +.control-button:hover, +.control-button:focus-visible { + border-color: rgba(139, 177, 255, 0.24); + background: rgba(121, 161, 239, 0.09); + color: #ecf2ff; +} + +.control-button.is-active { + border-color: rgba(94, 210, 166, 0.28); + background: rgba(76, 211, 156, 0.1); + color: #9ae2c4; +} + +.control-button:disabled { + cursor: not-allowed; + opacity: 0.42; +} + +.capture-button:not(:disabled) { + border-color: rgba(139, 177, 255, 0.2); + color: #dce7ff; +} + +.select-shell { + position: relative; + min-width: 130px; + max-width: 210px; +} + +.select-shell select { + width: 100%; + height: 34px; + padding: 0 30px 0 10px; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 9px; + outline: none; + appearance: none; + background: #11151d; + color: #b9c0cb; + cursor: pointer; + font-size: 10px; + text-overflow: ellipsis; +} + +.select-shell svg { + position: absolute; + top: 9px; + right: 9px; + width: 15px; + height: 15px; + pointer-events: none; + color: #667080; +} + +.view-controls { + justify-content: flex-end; + gap: 7px; +} + +.fov-control { + display: grid; + height: 34px; + align-items: center; + gap: 7px; + grid-template-columns: auto 82px 30px; + padding: 0 10px; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 9px; + background: rgba(255, 255, 255, 0.025); + color: #8e97a5; + font-size: 10px; +} + +.fov-control input { + width: 82px; + accent-color: #86aefe; + cursor: pointer; +} + +.fov-control output { + color: #ccd3de; + font-variant-numeric: tabular-nums; + text-align: right; +} + +.fullscreen-exit { + display: none; +} + +:fullscreen .fullscreen-enter { + display: none; +} + +:fullscreen .fullscreen-exit { + display: block; +} + +.image-meta { + position: absolute; + right: 17px; + bottom: 4px; + display: flex; + align-items: center; + gap: 7px; + color: #596372; + font-size: 8px; + font-variant-numeric: tabular-nums; +} + +.image-meta .meta-divider { + height: 8px; +} + +.toast { + position: fixed; + z-index: 10; + bottom: 96px; + left: 50%; + max-width: min(480px, calc(100% - 32px)); + padding: 9px 12px; + border: 1px solid rgba(255, 255, 255, 0.11); + border-radius: 9px; + background: rgba(22, 27, 37, 0.96); + color: #d8dee8; + box-shadow: 0 12px 36px rgba(0, 0, 0, 0.3); + font-size: 10px; + line-height: 1.45; + transform: translateX(-50%); +} + +.toast.is-warning { + border-color: rgba(255, 184, 91, 0.28); + color: #ffd39b; +} + +.toast.is-error { + border-color: rgba(255, 102, 115, 0.3); + color: #ffb2ba; +} + +.is-hidden { + display: none !important; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 760px) { + .app-shell { + grid-template-rows: 58px minmax(0, 1fr) auto; + } + + .brand-subtitle, + .connection-pill { + display: none; + } + + .control-deck { + grid-template-columns: 1fr; + padding-bottom: 17px; + } + + .source-section, + .view-controls { + justify-content: space-between; + } + + .source-copy { + display: none; + } + + .source-actions { + width: 100%; + } + + .select-shell { + max-width: none; + flex: 1; + } + + .fov-control { + flex: 1; + grid-template-columns: auto minmax(48px, 1fr) 30px; + } + + .fov-control input { + width: 100%; + } + + .control-button { + padding: 0 9px; + } + + .toast { + bottom: 132px; + } +} + +@media (max-height: 520px) { + .empty-orbit, + .feature-tags, + .empty-state p { + display: none; + } + + .empty-actions { + margin-top: 14px; + } + + .toast { + top: 66px; + bottom: auto; + } +} + +@media (max-width: 419px), (max-height: 359px) { + .app-shell { + grid-template-rows: 48px minmax(0, 1fr) auto; + } + + .topbar { + gap: 8px; + padding: 0 10px; + } + + .brand-mark { + width: 30px; + height: 30px; + } + + .control-deck { + min-height: 0; + gap: 7px; + padding: 8px 10px; + } + + .secondary-button, + .control-button, + .fov-control, + .select-shell select { + height: 30px; + } + + .empty-state { + padding: 14px; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/packages/plugins/panorama-viewer/package/index.html b/packages/plugins/panorama-viewer/package/index.html new file mode 100644 index 0000000..0093161 --- /dev/null +++ b/packages/plugins/panorama-viewer/package/index.html @@ -0,0 +1,169 @@ + + +
+ + +将画布中的图片连到此节点,或从本地选择 2:1 等距柱状投影图片。
+