参考主体
+从 Canvas 图片节点连线到“多角度”节点
+ JPEG · PNG · WebP · ≤ 16 MiB +连接范围由当前 Project、Canvas 与本节点共同约束。
+From c7627e47328c36bb39aed6dc06ceb19e61e35431 Mon Sep 17 00:00:00 2001 From: superche <35420957+superche@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:54:40 +0800 Subject: [PATCH] feat(plugin): add multi-angle grid generation --- bun.lock | 7 + .../plugins/multi-angle/convax-package.json | 14 + packages/plugins/multi-angle/package.json | 10 + packages/plugins/multi-angle/package/LICENSE | 21 + .../plugins/multi-angle/package/assets/app.js | 782 ++++++++++++++++++ .../package/assets/multi-angle-model.js | 255 ++++++ .../multi-angle/package/assets/styles.css | 490 +++++++++++ .../plugins/multi-angle/package/index.html | 175 ++++ .../plugins/multi-angle/package/manifest.json | 34 + registry/config.json | 2 +- tooling/multi-angle.test.js | 260 ++++++ tooling/registry.test.js | 22 + 12 files changed, 2071 insertions(+), 1 deletion(-) create mode 100644 packages/plugins/multi-angle/convax-package.json create mode 100644 packages/plugins/multi-angle/package.json create mode 100644 packages/plugins/multi-angle/package/LICENSE create mode 100644 packages/plugins/multi-angle/package/assets/app.js create mode 100644 packages/plugins/multi-angle/package/assets/multi-angle-model.js create mode 100644 packages/plugins/multi-angle/package/assets/styles.css create mode 100644 packages/plugins/multi-angle/package/index.html create mode 100644 packages/plugins/multi-angle/package/manifest.json create mode 100644 tooling/multi-angle.test.js diff --git a/bun.lock b/bun.lock index 366250d..1edc0d2 100644 --- a/bun.lock +++ b/bun.lock @@ -25,6 +25,10 @@ "name": "@microvoid/convax-plugin-hello-convax", "version": "0.1.0" }, + "packages/plugins/multi-angle": { + "name": "@microvoid/convax-plugin-multi-angle", + "version": "0.1.0" + }, "packages/plugins/relight-studio": { "name": "@microvoid/convax-plugin-relight-studio", "version": "0.1.2", @@ -177,6 +181,9 @@ "@microvoid/convax-plugin-hello-convax": [ "@microvoid/convax-plugin-hello-convax@workspace:packages/plugins/hello-convax" ], + "@microvoid/convax-plugin-multi-angle": [ + "@microvoid/convax-plugin-multi-angle@workspace:packages/plugins/multi-angle" + ], "@microvoid/convax-plugin-relight-studio": [ "@microvoid/convax-plugin-relight-studio@workspace:packages/plugins/relight-studio" ], diff --git a/packages/plugins/multi-angle/convax-package.json b/packages/plugins/multi-angle/convax-package.json new file mode 100644 index 0000000..5f0f68f --- /dev/null +++ b/packages/plugins/multi-angle/convax-package.json @@ -0,0 +1,14 @@ +{ + "schema": "convax.package/1", + "kind": "plugin", + "id": "multi-angle", + "name": "多角度", + "description": "通过已安装的统一图片生成工具,一次生成一张多宫格图片,每个格子展示同一主体的一个一致视角。", + "version": "0.1.0", + "license": "MIT", + "compatibility": { + "pluginSchema": "convax.plugin/3", + "pluginHost": "convax.plugin-host/3" + }, + "yanked": false +} diff --git a/packages/plugins/multi-angle/package.json b/packages/plugins/multi-angle/package.json new file mode 100644 index 0000000..93cee83 --- /dev/null +++ b/packages/plugins/multi-angle/package.json @@ -0,0 +1,10 @@ +{ + "name": "@microvoid/convax-plugin-multi-angle", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "validate": "bun ../../../tooling/validate.mjs --kind plugin --id multi-angle", + "pack": "bun ../../../tooling/pack.mjs --kind plugin --id multi-angle" + } +} diff --git a/packages/plugins/multi-angle/package/LICENSE b/packages/plugins/multi-angle/package/LICENSE new file mode 100644 index 0000000..0260f10 --- /dev/null +++ b/packages/plugins/multi-angle/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/multi-angle/package/assets/app.js b/packages/plugins/multi-angle/package/assets/app.js new file mode 100644 index 0000000..2b5cda2 --- /dev/null +++ b/packages/plugins/multi-angle/package/assets/app.js @@ -0,0 +1,782 @@ +import { + ANGLE_PRESETS, + MAX_SELECTED_PRESETS, + MIN_SELECTED_PRESETS, + SUBJECT_TYPES, + createDefaultState, + createGenerationRequest, + createMultiAngleGridPrompt, + executeGridGeneration, + hydratePluginState, + normalizeGenerationResult, + normalizeGenerationTools, + presetById, +} from "./multi-angle-model.js" + +const HOST_PROTOCOL = "convax.plugin-host/3" +const PLUGIN_ID = "multi-angle" +const CONNECTIONS_CHANGED_COMMAND = "canvas.connectedImages.changed" +const GENERATE_COMMAND = "multi-angle.generate" +const REFRESH_COMMAND = "multi-angle.refresh" +const REQUEST_TIMEOUT = 30000 +const STATE_SAVE_DELAY = 240 + +const elements = { + actionHint: document.getElementById("actionHint"), + actionTitle: document.getElementById("actionTitle"), + connectionPill: document.getElementById("connectionPill"), + connectionText: document.getElementById("connectionText"), + emptySource: document.getElementById("emptySource"), + generateButton: document.getElementById("generateButton"), + generateLabel: document.getElementById("generateLabel"), + loadingOverlay: document.getElementById("loadingOverlay"), + loadingText: document.getElementById("loadingText"), + messagePanel: document.getElementById("messagePanel"), + messageText: document.getElementById("messageText"), + messageTitle: document.getElementById("messageTitle"), + modelCount: document.getElementById("modelCount"), + notesCount: document.getElementById("notesCount"), + notesInput: document.getElementById("notesInput"), + presetGrid: document.getElementById("presetGrid"), + refreshButton: document.getElementById("refreshButton"), + resultsGrid: document.getElementById("resultsGrid"), + runStatus: document.getElementById("runStatus"), + runStatusText: document.getElementById("runStatusText"), + selectionCount: document.getElementById("selectionCount"), + sourceHelp: document.getElementById("sourceHelp"), + sourceImage: document.getElementById("sourceImage"), + sourceOverlay: document.getElementById("sourceOverlay"), + sourceSelect: document.getElementById("sourceSelect"), + sourceSelectShell: document.getElementById("sourceSelectShell"), + sourceSize: document.getElementById("sourceSize"), + sourceStage: document.getElementById("sourceStage"), + subjectTypes: document.getElementById("subjectTypes"), + toast: document.getElementById("toast"), + toolHelp: document.getElementById("toolHelp"), + toolSelect: document.getElementById("toolSelect"), +} + +let hostPort = null +let requestSequence = 0 +let pendingRequests = new Map() +let pluginContext = null +let pluginState = createDefaultState() +let hydrationSource = "empty" +let connectedImages = [] +let generationTools = [] +let sourceDataUrl = "" +let sourceLoadSequence = 0 +let refreshPromise = null +let refreshQueued = false +let runActive = false +let stateWritesSuspended = false +let stateSaveTimer = 0 +let stateSaveDirty = false +let stateSavePromise = null +let toastTimer = 0 + +function isRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function setHidden(element, hidden) { + element.classList.toggle("is-hidden", hidden) +} + +function errorMessage(error, fallback) { + return error instanceof Error && error.message ? error.message : fallback +} + +function showToast(message, tone = "info") { + window.clearTimeout(toastTimer) + elements.toast.textContent = message + elements.toast.classList.remove("is-hidden", "is-error", "is-warning") + if (tone === "error") elements.toast.classList.add("is-error") + if (tone === "warning") elements.toast.classList.add("is-warning") + toastTimer = window.setTimeout(function () { + elements.toast.classList.add("is-hidden") + }, tone === "error" ? 5600 : 3600) +} + +function setConnectionState(connected) { + elements.connectionPill.classList.toggle("is-connected", connected) + elements.connectionText.textContent = connected ? "画布已连接" : "等待宿主" +} + +function setLoading(active, text) { + if (text) elements.loadingText.textContent = text + setHidden(elements.loadingOverlay, !active) +} + +function rejectPendingRequests(error) { + for (const pending of pendingRequests.values()) { + if (pending.timeout !== null) window.clearTimeout(pending.timeout) + pending.reject(error) + } + pendingRequests.clear() +} + +function hostRequest(method, params, timeoutMs) { + if (!hostPort) return Promise.reject(new Error("Convax Plugin host is not connected")) + const id = "multi-angle-" + String(++requestSequence) + const request = { + id, + method, + ...(params === undefined ? {} : { params }), + protocol: HOST_PROTOCOL, + type: "request", + } + return new Promise(function (resolve, reject) { + const requestTimeout = timeoutMs === undefined ? REQUEST_TIMEOUT : timeoutMs + const timeout = requestTimeout === null ? null : window.setTimeout(function () { + pendingRequests.delete(id) + reject(new Error("插件宿主请求超时")) + }, requestTimeout) + pendingRequests.set(id, { reject, resolve, timeout }) + try { + hostPort.postMessage(request) + } catch (error) { + if (timeout !== null) window.clearTimeout(timeout) + pendingRequests.delete(id) + reject(error) + } + }) +} + +function handlePortMessage(event) { + const message = event.data + if (!isRecord(message) || message.protocol !== HOST_PROTOCOL) return + if (message.type === "response" && typeof message.id === "string" && typeof message.ok === "boolean") { + const pending = pendingRequests.get(message.id) + if (!pending) return + pendingRequests.delete(message.id) + if (pending.timeout !== null) window.clearTimeout(pending.timeout) + if (message.ok) pending.resolve(message.result) + else pending.reject(new Error(typeof message.error === "string" ? message.error : "Convax Plugin request failed")) + return + } + if (message.type !== "command" || typeof message.command !== "string") return + if (message.command === CONNECTIONS_CHANGED_COMMAND || message.command === REFRESH_COMMAND) { + if (runActive) refreshQueued = true + else void refreshAll(true) + } else if (message.command === GENERATE_COMMAND) { + void runGeneration() + } +} + +function handleWindowMessage(event) { + if (hostPort || event.source !== window.parent || event.ports.length !== 1) return + const message = event.data + if (!isRecord(message) || message.protocol !== HOST_PROTOCOL || message.type !== "connect" || message.pluginId !== PLUGIN_ID) return + window.removeEventListener("message", handleWindowMessage) + hostPort = event.ports[0] + hostPort.onmessage = handlePortMessage + hostPort.start() + setConnectionState(true) + void hydrateFromHost() +} + +function pluginStateFromContext(context) { + if (!isRecord(context) || !isRecord(context.node) || !isRecord(context.node.data)) return null + const metadata = context.node.data.metadata + return isRecord(metadata) ? metadata.convaxPluginState : null +} + +function queueStateSave() { + stateSaveDirty = true + window.clearTimeout(stateSaveTimer) + if (stateWritesSuspended) return + stateSaveTimer = window.setTimeout(function () { void flushStateSave() }, STATE_SAVE_DELAY) +} + +async function flushStateSave() { + window.clearTimeout(stateSaveTimer) + if (!hostPort || stateWritesSuspended) return + if (stateSavePromise) { + await stateSavePromise + if (stateSaveDirty && !stateWritesSuspended) return flushStateSave() + return + } + if (!stateSaveDirty) return + const snapshot = pluginState + stateSaveDirty = false + stateSavePromise = hostRequest("canvas.node.updateState", { state: snapshot }) + try { + await stateSavePromise + } catch (error) { + stateSaveDirty = true + showToast(errorMessage(error, "无法保存多角度配置"), "error") + throw error + } finally { + stateSavePromise = null + } + if (stateSaveDirty && !stateWritesSuspended) await flushStateSave() +} + +function postStateSnapshotBestEffort() { + if (!hostPort || runActive || stateWritesSuspended || hydrationSource === "unsupported") return + try { + hostPort.postMessage({ + id: "multi-angle-unload-" + String(++requestSequence), + method: "canvas.node.updateState", + params: { state: pluginState }, + protocol: HOST_PROTOCOL, + type: "request", + }) + } catch { + // The owning Canvas keeps the last state snapshot already accepted by the host. + } +} + +async function hydrateFromHost() { + try { + const context = await hostRequest("host.context.get") + if (!isRecord(context) || !isRecord(context.canvas) || !isRecord(context.node)) { + throw new Error("宿主返回了无效的插件上下文") + } + pluginContext = context + const hydrated = hydratePluginState(pluginStateFromContext(context)) + hydrationSource = hydrated.source + pluginState = hydrated.state + renderAll() + await refreshAll(true) + } catch (error) { + showToast(errorMessage(error, "无法读取插件上下文"), "error") + } +} + +function normalizeConnectedImages(result) { + if (!isRecord(result) || !Array.isArray(result.images)) return [] + return result.images.filter(function (image) { + return isRecord(image) && 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, + } + }) +} + +function chooseSourceImage() { + return connectedImages.find((image) => image.id === pluginState.sourceNodeId && image.readable) + ?? connectedImages.find((image) => image.readable) + ?? null +} + +async function readSelectedSource(force) { + const source = chooseSourceImage() + const sequence = ++sourceLoadSequence + if (!source) { + sourceDataUrl = "" + setLoading(false) + renderSource() + renderActions() + return + } + if (!force && source.id === pluginState.sourceNodeId && sourceDataUrl) return + sourceDataUrl = "" + setLoading(true, "正在读取参考图…") + renderSource() + try { + const result = await hostRequest("canvas.connectedImage.read", { nodeId: source.id }) + if (!isRecord(result) || typeof result.dataUrl !== "string" || typeof result.mimeType !== "string") { + throw new Error("宿主没有返回可预览的参考图") + } + if (sequence === sourceLoadSequence && pluginState.sourceNodeId === source.id) sourceDataUrl = result.dataUrl + } catch (error) { + if (sequence === sourceLoadSequence) showToast(errorMessage(error, "参考图预览失败;仍可尝试通过统一生成接口处理该节点。"), "warning") + } finally { + if (sequence === sourceLoadSequence) { + setLoading(false) + renderSource() + renderActions() + } + } +} + +async function refreshTools() { + const result = await hostRequest("generation.tools.list", { output: "image" }) + generationTools = normalizeGenerationTools(result) + if (!generationTools.some((tool) => tool.id === pluginState.toolId)) { + pluginState = { ...pluginState, toolId: generationTools[0]?.id ?? null } + } +} + +async function refreshConnectedImages(force) { + const previousSourceId = pluginState.sourceNodeId + connectedImages = normalizeConnectedImages(await hostRequest("canvas.connectedImages.list")) + const source = chooseSourceImage() + if (source?.id !== previousSourceId) { + pluginState = { + ...pluginState, + lastRun: null, + result: null, + sourceNodeId: source?.id ?? null, + } + sourceDataUrl = "" + } + renderAll() + await readSelectedSource(force) +} + +async function refreshAll(force = false) { + if (!hostPort) return + if (runActive) { + refreshQueued = true + return + } + if (refreshPromise) { + refreshQueued = refreshQueued || force + return refreshPromise + } + refreshPromise = (async function () { + try { + await refreshTools() + await refreshConnectedImages(force) + renderAll() + } catch (error) { + showToast(errorMessage(error, "刷新参考图与生图模型失败"), "error") + } + })() + try { + await refreshPromise + } finally { + refreshPromise = null + if (refreshQueued && !runActive) { + const queuedForce = refreshQueued + refreshQueued = false + void refreshAll(queuedForce) + } + } +} + +function resetRunForPlanChange() { + pluginState = { ...pluginState, lastRun: null, result: null } + hydrationSource = "current" +} + +function renderSubjectTypes() { + const focusedElement = document.activeElement + const focusedId = focusedElement instanceof HTMLElement && elements.subjectTypes.contains(focusedElement) + ? focusedElement.dataset.subjectId + : null + let focusTarget = null + elements.subjectTypes.replaceChildren() + for (const subject of SUBJECT_TYPES) { + const button = document.createElement("button") + button.className = "segment-button" + (pluginState.subjectType === subject.id ? " is-active" : "") + button.dataset.subjectId = subject.id + button.disabled = runActive + button.type = "button" + button.textContent = subject.label + button.setAttribute("aria-pressed", String(pluginState.subjectType === subject.id)) + button.addEventListener("click", function () { + if (runActive || pluginState.subjectType === subject.id) return + pluginState = { ...pluginState, subjectType: subject.id } + resetRunForPlanChange() + queueStateSave() + renderAll() + }) + if (focusedId === subject.id) focusTarget = button + elements.subjectTypes.append(button) + } + focusTarget?.focus({ preventScroll: true }) +} + +function renderPresetButtons() { + const focusedElement = document.activeElement + const focusedId = focusedElement instanceof HTMLElement && elements.presetGrid.contains(focusedElement) + ? focusedElement.dataset.presetId + : null + let focusTarget = null + elements.presetGrid.replaceChildren() + for (const preset of ANGLE_PRESETS) { + const active = pluginState.selectedPresetIds.includes(preset.id) + const button = document.createElement("button") + button.className = "preset-button" + (active ? " is-active" : "") + button.dataset.presetId = preset.id + button.disabled = runActive + button.type = "button" + button.setAttribute("aria-pressed", String(active)) + const copy = document.createElement("span") + const label = document.createElement("strong") + const shortLabel = document.createElement("small") + const check = document.createElement("span") + label.textContent = preset.label + shortLabel.textContent = preset.shortLabel + check.className = "preset-check" + check.textContent = "✓" + copy.append(label, shortLabel) + button.append(copy, check) + button.addEventListener("click", function () { + if (runActive) return + const selected = pluginState.selectedPresetIds + if (!active && selected.length >= MAX_SELECTED_PRESETS) { + showToast("单次最多选择 " + String(MAX_SELECTED_PRESETS) + " 个视角。", "warning") + return + } + pluginState = { + ...pluginState, + selectedPresetIds: active ? selected.filter((id) => id !== preset.id) : [...selected, preset.id], + } + resetRunForPlanChange() + queueStateSave() + renderAll() + }) + if (focusedId === preset.id) focusTarget = button + elements.presetGrid.append(button) + } + focusTarget?.focus({ preventScroll: true }) +} + +function renderTools() { + elements.toolSelect.replaceChildren() + if (!generationTools.length) { + const option = document.createElement("option") + option.textContent = "未发现可用 AI 图片模型" + option.value = "" + elements.toolSelect.append(option) + elements.toolSelect.disabled = true + elements.modelCount.textContent = "0 个可用" + elements.toolHelp.textContent = "请先安装并配置一个声明为 model 且支持参考图的图片生成 Tool Plugin。" + return + } + for (const tool of generationTools) { + const option = document.createElement("option") + option.textContent = tool.title + option.value = tool.id + elements.toolSelect.append(option) + } + elements.toolSelect.value = pluginState.toolId ?? generationTools[0].id + elements.toolSelect.disabled = runActive + elements.modelCount.textContent = String(generationTools.length) + " 个可用" + const selected = generationTools.find((tool) => tool.id === elements.toolSelect.value) + elements.toolHelp.textContent = selected?.description || "模型由已安装的统一 Generation Tool Plugin 提供。" +} + +function renderSource() { + elements.sourceSelect.replaceChildren() + for (const image of connectedImages) { + const option = document.createElement("option") + option.value = image.id + option.disabled = !image.readable + option.textContent = image.name + (image.readable ? "" : "(不可读取)") + elements.sourceSelect.append(option) + } + if (pluginState.sourceNodeId) elements.sourceSelect.value = pluginState.sourceNodeId + elements.sourceSelect.disabled = runActive + setHidden(elements.sourceSelectShell, connectedImages.length === 0) + + const source = chooseSourceImage() + const hasPreview = Boolean(source && sourceDataUrl) + elements.sourceStage.classList.toggle("has-image", hasPreview) + setHidden(elements.emptySource, hasPreview) + setHidden(elements.sourceImage, !hasPreview) + setHidden(elements.sourceOverlay, !hasPreview) + if (hasPreview) { + elements.sourceImage.src = sourceDataUrl + elements.sourceImage.alt = "多角度参考图:" + source.name + elements.sourceSize.textContent = source.width && source.height + ? String(source.width) + " × " + String(source.height) + : source.mimeType ?? "IMAGE" + } else { + elements.sourceImage.removeAttribute("src") + } + if (!connectedImages.length) elements.sourceHelp.textContent = "从 Canvas 图片节点连线到此节点;插件不能读取未连接的素材。" + else if (!source) elements.sourceHelp.textContent = "当前连接中没有可用的 JPEG、PNG 或 WebP 图片。" + else if (!hasPreview) elements.sourceHelp.textContent = "当前参考:" + source.name + "。预览不可用时仍可由宿主尝试生成。" + else elements.sourceHelp.textContent = "当前参考:" + source.name + "。切换来源会开始一份新的镜头方案。" +} + +function resultCard(presetIds) { + const result = pluginState.result + const failed = Boolean(pluginState.lastRun?.failure) && !runActive + const running = runActive + const card = document.createElement("article") + card.className = "result-card" + (result ? " is-success" : running ? " is-running" : failed ? " is-failed" : "") + + const heading = document.createElement("div") + heading.className = "result-heading" + const angle = document.createElement("div") + angle.className = "result-angle" + const name = document.createElement("strong") + const shortLabel = document.createElement("small") + name.textContent = "多角度宫格图" + shortLabel.textContent = presetIds.map((presetId) => presetById(presetId)?.shortLabel ?? presetId).join(" · ") + angle.append(name, shortLabel) + const icon = document.createElement("span") + icon.className = "result-icon" + icon.textContent = result ? "✓" : failed ? "!" : running ? "◌" : "◇" + heading.append(angle, icon) + + const copy = document.createElement("div") + copy.className = "result-copy" + const summary = document.createElement("span") + const nodeIds = document.createElement("code") + if (result) { + summary.textContent = "已创建 " + String(result.createdNodeIds.length) + " 个 Canvas 图片节点,内容为 " + String(result.presetIds.length) + " 宫格" + nodeIds.textContent = result.createdNodeIds.join(" · ") + } else if (running) { + summary.textContent = "正在通过统一接口一次生成完整的 " + String(presetIds.length) + " 宫格图…" + nodeIds.textContent = "关闭或移除插件节点会由宿主取消当前任务" + } else if (failed) { + summary.textContent = "本次宫格图未完成" + nodeIds.textContent = pluginState.lastRun.failure.message + } else { + summary.textContent = "等待一次生成整张多角度宫格图" + nodeIds.textContent = "最终只会在 Canvas 中创建一张结果图片" + } + copy.append(summary, nodeIds) + + const meta = document.createElement("div") + meta.className = "result-meta" + const status = document.createElement("span") + status.className = "result-status" + (result ? " is-success" : running ? " is-running" : failed ? " is-failed" : "") + status.textContent = result ? "已提交" : running ? "生成中" : failed ? "失败" : "待生成" + const warnings = document.createElement("span") + warnings.textContent = result?.warnings.length ? String(result.warnings.length) + " 条提示" : "" + meta.append(status, warnings) + card.append(heading, copy, meta) + return card +} + +function renderResults() { + const run = pluginState.lastRun + const presetIds = run?.presetIds ?? pluginState.selectedPresetIds + elements.resultsGrid.replaceChildren(resultCard(presetIds)) + elements.runStatus.className = "run-status is-idle" + let statusText = "等待生成" + if (runActive) { + elements.runStatus.className = "run-status is-running" + statusText = "正在生成 1 张 " + String(presetIds.length) + " 宫格图" + } else if (run?.status === "success") { + elements.runStatus.className = "run-status is-success" + statusText = "已完成 1 张 " + String(run.presetIds.length) + " 宫格图" + } else if (run?.status === "failed") { + elements.runStatus.className = "run-status is-error" + statusText = "生成失败" + } else if (run?.status === "interrupted") { + elements.runStatus.className = "run-status is-warning" + statusText = "上次任务已中断" + } + elements.runStatusText.textContent = statusText + + let title = "" + let message = "" + let tone = "" + if (hydrationSource === "unsupported") { + title = "状态版本不受支持" + message = "当前节点保留了未知版本状态;只有在你主动修改或生成后,插件才会写入新格式。" + } else if (!generationTools.length && hostPort) { + title = "没有可用的 AI 图片模型" + message = "安装并配置一个支持 reference_image 的图片 model Tool Plugin 后,再刷新此节点。" + } else if (run?.failure) { + title = run.status === "interrupted" ? "上次生成被中断" : "宫格图生成失败" + message = run.failure.message + tone = run.status === "failed" ? "is-error" : "" + } else { + const warningCount = pluginState.result?.warnings.length ?? 0 + if (warningCount) { + title = "生成工具提示" + message = "本次返回 " + String(warningCount) + " 条工具提示;宫格结果节点已由宿主正常提交到 Canvas。" + } + } + elements.messagePanel.className = "message-panel" + (tone ? " " + tone : "") + setHidden(elements.messagePanel, !message) + elements.messageTitle.textContent = title + elements.messageText.textContent = message +} + +function renderActions() { + const selectedCount = pluginState.selectedPresetIds.length + const source = chooseSourceImage() + const tool = generationTools.find((candidate) => candidate.id === pluginState.toolId) + elements.selectionCount.textContent = "已选 " + String(selectedCount) + " / " + String(MAX_SELECTED_PRESETS) + elements.notesCount.value = pluginState.notes.length + " / 1000" + elements.notesCount.textContent = pluginState.notes.length + " / 1000" + if (elements.notesInput.value !== pluginState.notes) elements.notesInput.value = pluginState.notes + elements.notesInput.disabled = runActive + elements.refreshButton.disabled = runActive + elements.actionTitle.textContent = selectedCount >= MIN_SELECTED_PRESETS + ? "准备生成 1 张 " + String(selectedCount) + " 宫格图片" + : "至少选择两个视角" + if (!source) elements.actionHint.textContent = "先从 Canvas 连接并选择一张参考图" + else if (!tool) elements.actionHint.textContent = "先安装或选择一个支持参考图的 AI 图片模型" + else elements.actionHint.textContent = "将通过“" + tool.title + "”发起 1 次统一生图,输出一张多宫格图片" + elements.generateButton.disabled = runActive || !hostPort || !pluginContext || !source || !tool + || selectedCount < MIN_SELECTED_PRESETS + elements.generateLabel.textContent = runActive ? "宫格图生成中…" : "生成宫格图" +} + +function renderAll() { + renderTools() + renderSubjectTypes() + renderPresetButtons() + renderSource() + renderResults() + renderActions() +} + +async function runGeneration() { + if (runActive) return + const source = chooseSourceImage() + const tool = generationTools.find((candidate) => candidate.id === pluginState.toolId) + const presetIds = [...pluginState.selectedPresetIds] + if (!source) { + showToast("请先连接并选择一张参考图。", "warning") + return + } + if (!tool) { + showToast("请先安装或选择一个可用的 AI 图片模型。", "warning") + return + } + if (presetIds.length < MIN_SELECTED_PRESETS) { + showToast("请至少选择两个视角。", "warning") + return + } + + runActive = true + const startedAt = new Date().toISOString() + pluginState = { + ...pluginState, + lastRun: { + completedAt: "", + failure: null, + presetIds, + sourceNodeId: source.id, + startedAt, + status: "running", + toolId: tool.id, + }, + result: null, + sourceNodeId: source.id, + toolId: tool.id, + } + hydrationSource = "current" + queueStateSave() + renderAll() + + try { + await flushStateSave() + } catch { + runActive = false + pluginState = { + ...pluginState, + lastRun: { + ...pluginState.lastRun, + completedAt: new Date().toISOString(), + failure: { message: "无法在生成前保存当前宫格方案。" }, + status: "failed", + }, + } + renderAll() + return + } + + stateWritesSuspended = true + const outcome = await executeGridGeneration({ + execute: async function () { + const prompt = createMultiAngleGridPrompt({ + notes: pluginState.notes, + presetIds, + subjectType: pluginState.subjectType, + }) + const request = createGenerationRequest({ prompt, sourceNodeId: source.id, toolId: tool.id }) + // Generation has no client deadline. The host owns queued-job polling, + // frame cancellation, stale-scope checks, managed assets and Canvas commit. + const rawResult = await hostRequest("generation.canvas.execute", request, null) + return normalizeGenerationResult(rawResult, presetIds, new Date().toISOString()) + }, + }) + + const completedAt = new Date().toISOString() + pluginState = { + ...pluginState, + lastRun: { + ...pluginState.lastRun, + completedAt, + failure: outcome.failure, + status: outcome.failure ? "failed" : "success", + }, + result: outcome.result, + } + stateWritesSuspended = false + queueStateSave() + try { + await flushStateSave() + } catch { + // The Canvas results are already authoritative even if this UI snapshot cannot be saved. + } + runActive = false + renderAll() + if (outcome.failure) { + showToast("多角度宫格图生成失败。", "error") + } else { + showToast("已将一张 " + String(presetIds.length) + " 宫格多角度图提交到 Canvas。") + } + if (refreshQueued) { + refreshQueued = false + void refreshAll(true) + } +} + +function bindEvents() { + window.addEventListener("message", handleWindowMessage) + window.addEventListener("beforeunload", function () { + postStateSnapshotBestEffort() + window.clearTimeout(stateSaveTimer) + window.clearTimeout(toastTimer) + rejectPendingRequests(new Error("插件页面已关闭")) + if (hostPort) { + hostPort.onmessage = null + hostPort.close() + hostPort = null + } + }) + elements.refreshButton.addEventListener("click", function () { + if (!runActive) void refreshAll(true) + }) + elements.generateButton.addEventListener("click", function () { void runGeneration() }) + elements.sourceSelect.addEventListener("change", function () { + if (runActive) return + const source = connectedImages.find((image) => image.id === elements.sourceSelect.value && image.readable) + if (!source || source.id === pluginState.sourceNodeId) return + pluginState = { ...pluginState, sourceNodeId: source.id } + sourceDataUrl = "" + resetRunForPlanChange() + queueStateSave() + renderAll() + void readSelectedSource(true) + }) + elements.toolSelect.addEventListener("change", function () { + if (runActive || !generationTools.some((tool) => tool.id === elements.toolSelect.value)) return + pluginState = { ...pluginState, toolId: elements.toolSelect.value } + resetRunForPlanChange() + queueStateSave() + renderAll() + }) + elements.notesInput.addEventListener("input", function () { + if (runActive) return + pluginState = { ...pluginState, notes: elements.notesInput.value.slice(0, 1000) } + resetRunForPlanChange() + queueStateSave() + renderResults() + renderActions() + }) + elements.notesInput.addEventListener("change", function () { + if (!runActive) void flushStateSave() + }) +} + +function boot() { + bindEvents() + setConnectionState(false) + renderAll() +} + +boot() diff --git a/packages/plugins/multi-angle/package/assets/multi-angle-model.js b/packages/plugins/multi-angle/package/assets/multi-angle-model.js new file mode 100644 index 0000000..50d1b67 --- /dev/null +++ b/packages/plugins/multi-angle/package/assets/multi-angle-model.js @@ -0,0 +1,255 @@ +export const MAX_SELECTED_PRESETS = 6 +export const MIN_SELECTED_PRESETS = 2 +export const STATE_SCHEMA_VERSION = 3 + +export const ANGLE_PRESETS = Object.freeze([ + { id: "front", label: "正面", shortLabel: "Front", prompt: "eye-level front view, camera centered on the subject" }, + { id: "three-quarter", label: "左前 3/4", shortLabel: "3/4", prompt: "left-front three-quarter view with clear depth and silhouette" }, + { id: "left", label: "左侧", shortLabel: "Left", prompt: "true left profile view at the same subject scale" }, + { id: "right", label: "右侧", shortLabel: "Right", prompt: "true right profile view at the same subject scale" }, + { id: "back", label: "背面", shortLabel: "Back", prompt: "straight rear view showing the back design clearly" }, + { id: "top", label: "俯视", shortLabel: "Top", prompt: "high top-down view while preserving recognizable proportions" }, + { id: "low", label: "低机位", shortLabel: "Low", prompt: "low-angle hero view with restrained perspective distortion" }, + { id: "cinematic", label: "电影感", shortLabel: "Cinematic", prompt: "cinematic three-quarter view with intentional depth and composition" }, +]) + +export const SUBJECT_TYPES = Object.freeze([ + { id: "character", label: "人物 / 角色", prompt: "白底角色设定图", subject: "角色" }, + { id: "product", label: "产品 / 物件", prompt: "干净棚拍背景的产品设定图", subject: "产品或物件" }, + { id: "scene", label: "场景 / 空间", prompt: "统一画风的场景概念设定图", subject: "场景或空间" }, +]) + +const DEFAULT_PRESET_IDS = ["front", "left", "top", "cinematic"] +const PRESET_IDS = new Set(ANGLE_PRESETS.map((preset) => preset.id)) +const SUBJECT_IDS = new Set(SUBJECT_TYPES.map((subject) => subject.id)) +const TERMINAL_RUN_STATUSES = new Set(["success", "failed", "interrupted"]) + +function isRecord(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value) +} + +function safeText(value, maximum = 1000) { + return typeof value === "string" ? value.slice(0, maximum) : "" +} + +function safeId(value, maximum = 512) { + return typeof value === "string" && value.length > 0 && value.length <= maximum + && !/[\u0000-\u001f\u007f]/u.test(value) + ? value + : null +} + +function uniquePresetIds(value) { + if (!Array.isArray(value)) return [] + return [...new Set(value.filter((item) => safeId(item) && PRESET_IDS.has(item)))].slice(0, MAX_SELECTED_PRESETS) +} + +function normalizedWarnings(value) { + if (!Array.isArray(value)) return [] + return value + .filter((warning) => typeof warning === "string" && warning.length > 0) + .slice(0, 32) + .map((warning) => warning.slice(0, 2000)) +} + +function normalizedNodeIds(value) { + if (!Array.isArray(value)) return [] + return [...new Set(value.map((id) => safeId(id)).filter(Boolean))].slice(0, 32) +} + +export function presetById(id) { + return ANGLE_PRESETS.find((preset) => preset.id === id) ?? null +} + +export function createDefaultState() { + return { + lastRun: null, + notes: "", + result: null, + schemaVersion: STATE_SCHEMA_VERSION, + selectedPresetIds: [...DEFAULT_PRESET_IDS], + sourceNodeId: null, + subjectType: "character", + toolId: null, + } +} + +export function normalizeGenerationResult(value, presetIds, completedAt) { + const normalizedPresetIds = uniquePresetIds(presetIds) + if (!isRecord(value) || normalizedPresetIds.length < MIN_SELECTED_PRESETS) throw new Error("生成结果无效") + const createdNodeIds = normalizedNodeIds(value.createdNodeIds) + const toolId = safeId(value.toolId, 256) + if (createdNodeIds.length === 0 || !toolId || !Number.isSafeInteger(value.revision) || value.revision < 0) { + throw new Error("生成结果无效") + } + return { + completedAt: safeText(completedAt, 64), + createdNodeIds, + presetIds: normalizedPresetIds, + revision: value.revision, + toolId, + warnings: normalizedWarnings(value.warnings), + } +} + +function normalizePersistedResult(value) { + if (!isRecord(value)) return null + try { + return normalizeGenerationResult(value, value.presetIds, value.completedAt) + } catch { + return null + } +} + +function normalizeFailure(value) { + if (!isRecord(value)) return null + const message = safeText(value.message, 280) + return message ? { message } : null +} + +function normalizeLastRun(value) { + if (!isRecord(value)) return null + const presetIds = uniquePresetIds(value.presetIds) + const sourceNodeId = safeId(value.sourceNodeId) + const toolId = safeId(value.toolId, 256) + if (presetIds.length < MIN_SELECTED_PRESETS || !sourceNodeId || !toolId) return null + if (value.status === "running") { + return { + completedAt: "", + failure: { message: "插件页面在宫格图生成完成前关闭。宿主会保留已提交的 Canvas 结果。" }, + presetIds, + sourceNodeId, + startedAt: safeText(value.startedAt, 64), + status: "interrupted", + toolId, + } + } + if (!TERMINAL_RUN_STATUSES.has(value.status)) return null + return { + completedAt: safeText(value.completedAt, 64), + failure: normalizeFailure(value.failure), + presetIds, + sourceNodeId, + startedAt: safeText(value.startedAt, 64), + status: value.status, + toolId, + } +} + +function hydrateCurrentState(value) { + const fallback = createDefaultState() + const selectedPresetIds = uniquePresetIds(value.selectedPresetIds) + return { + lastRun: normalizeLastRun(value.lastRun), + notes: safeText(value.notes, 1000), + result: normalizePersistedResult(value.result), + schemaVersion: STATE_SCHEMA_VERSION, + selectedPresetIds: selectedPresetIds.length ? selectedPresetIds : fallback.selectedPresetIds, + sourceNodeId: safeId(value.sourceNodeId), + subjectType: SUBJECT_IDS.has(value.subjectType) ? value.subjectType : fallback.subjectType, + toolId: safeId(value.toolId, 256), + } +} + +function migrateLegacyState(value) { + const fallback = createDefaultState() + const selectedPresetIds = uniquePresetIds(value.selectedPresetIds) + return { + ...fallback, + notes: safeText(value.notes, 1000), + selectedPresetIds: selectedPresetIds.length ? selectedPresetIds : fallback.selectedPresetIds, + sourceNodeId: safeId(value.sourceNodeId), + subjectType: SUBJECT_IDS.has(value.subjectType) ? value.subjectType : fallback.subjectType, + toolId: safeId(value.toolId, 256), + } +} + +export function hydratePluginState(value) { + if (value === null || value === undefined) return { source: "empty", state: createDefaultState() } + if (!isRecord(value)) return { source: "unsupported", state: createDefaultState() } + if (value.schemaVersion === STATE_SCHEMA_VERSION) return { source: "current", state: hydrateCurrentState(value) } + if (value.schemaVersion === 1 || value.schemaVersion === 2) { + return { source: "legacy", state: migrateLegacyState(value) } + } + return { source: "unsupported", state: createDefaultState() } +} + +export function normalizeGenerationTools(value) { + if (!isRecord(value) || !Array.isArray(value.tools)) return [] + const seen = new Set() + const tools = [] + for (const candidate of value.tools) { + if (!isRecord(candidate) || candidate.kind !== "model" || candidate.output !== "image") continue + if (!Array.isArray(candidate.acceptedInputs) || !candidate.acceptedInputs.includes("reference_image")) continue + const id = safeId(candidate.id, 256) + const title = safeText(candidate.title, 120) + if (!id || !title || seen.has(id)) continue + seen.add(id) + tools.push({ + acceptedInputs: [...new Set(candidate.acceptedInputs.filter((role) => typeof role === "string"))], + description: safeText(candidate.description, 2000), + id, + kind: "model", + output: "image", + title, + }) + } + return tools +} + +function gridLayout(count) { + if (count === 2) return "1 行 × 2 列" + if (count === 3) return "1 行 × 3 列" + if (count === 4) return "2 行 × 2 列" + if (count === 5) return "2 行布局(第一行 3 格,第二行 2 格居中)" + if (count === 6) return "2 行 × 3 列" + return "等分宫格" +} + +export function createMultiAngleGridPrompt(input) { + const presetIds = uniquePresetIds(input.presetIds) + const subject = SUBJECT_TYPES.find((item) => item.id === input.subjectType) + if (presetIds.length < MIN_SELECTED_PRESETS || !subject) throw new Error("镜头方案无效") + const presets = presetIds.map((presetId) => presetById(presetId)) + if (presets.some((preset) => !preset)) throw new Error("镜头方案无效") + const notes = safeText(input.notes, 1000).trim() + return [ + `严格基于参考图中的同一主体,生成一张标准${subject.prompt}。`, + `最终只输出一张图片,采用${gridLayout(presets.length)}的${presets.length}宫格;各格尺寸一致、边界清晰、构图整齐。`, + "按从左到右、从上到下的阅读顺序,每个格子只展示同一主体的一个视角:", + ...presets.map((preset, index) => `第 ${index + 1} 格:${preset.label}(${preset.prompt})。`), + `每个格子只出现一个完整的${subject.subject},保持相近景别、主体比例和视觉重心。`, + "参考图是主体身份、轮廓、比例、五官或结构、发型、服饰或材质、颜色、标志性细节与画风的唯一依据;只改变观察机位,谨慎推断新露出的表面。", + "所有格子中的主体必须完全一致,光线逻辑、背景风格与镜头质感保持统一;不同格子不得变成不同人物、不同产品或不同场景。", + "不要把视角拆成多张图片,不要在单个格子中重复主体,不要增加未选择的视角,也不要生成文字标签、标题、说明、边框文字、水印、Logo 或 UI。", + "这是一张多角度视觉一致性设定图,不代表几何精确的真实 3D 或工业 CAD 重建。", + ...(notes ? [`额外一致性要求:${notes}`] : []), + ].join("\n") +} + +export function createGenerationRequest(input) { + const sourceNodeId = safeId(input.sourceNodeId) + const toolId = safeId(input.toolId, 256) + const prompt = safeText(input.prompt, 20_000).trim() + if (!sourceNodeId || !toolId || !prompt) throw new Error("生成请求无效") + return { + output: "image", + prompt, + references: [{ nodeId: sourceNodeId, role: "reference_image" }], + resultMode: "create-pending-node", + toolId, + } +} + +function failureMessage(error) { + return safeText(error instanceof Error ? error.message : "Canvas generation could not be completed", 280) + || "Canvas generation could not be completed" +} + +export async function executeGridGeneration(input) { + try { + return { failure: null, result: await input.execute() } + } catch (error) { + return { failure: { message: failureMessage(error) }, result: null } + } +} diff --git a/packages/plugins/multi-angle/package/assets/styles.css b/packages/plugins/multi-angle/package/assets/styles.css new file mode 100644 index 0000000..119c528 --- /dev/null +++ b/packages/plugins/multi-angle/package/assets/styles.css @@ -0,0 +1,490 @@ +:root { + color-scheme: dark; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --bg: #090a0f; + --panel: rgba(20, 22, 31, 0.95); + --line: rgba(255, 255, 255, 0.09); + --line-strong: rgba(255, 255, 255, 0.16); + --text: #f7f7fa; + --muted: #9297a8; + --muted-strong: #bcc0cc; + --accent: #8b7cff; + --accent-strong: #ad8dff; + --accent-soft: rgba(139, 124, 255, 0.16); + --cyan: #6ee7f2; + --success: #63dfaa; + --warning: #f5bd68; + --danger: #ff7d8e; +} + +* { box-sizing: border-box; } +html, body { min-height: 100%; } + +body { + margin: 0; + color: var(--text); + background: + radial-gradient(circle at 14% -8%, rgba(120, 104, 255, 0.2), transparent 34%), + radial-gradient(circle at 92% 0%, rgba(62, 194, 217, 0.1), transparent 26%), + var(--bg); +} + +button, select, textarea { color: inherit; font: inherit; } +button, select { cursor: pointer; } +button:disabled, select:disabled { cursor: not-allowed; } +svg { + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; +} + +.is-hidden { display: none !important; } +.app-shell { min-height: 100vh; display: flex; flex-direction: column; } + +.topbar { + min-height: 76px; + padding: 14px 22px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + border-bottom: 1px solid var(--line); + background: rgba(9, 10, 15, 0.72); + backdrop-filter: blur(18px); +} + +.brand, .brand-line, .topbar-meta, .panel-heading, .panel-heading > div, +.results-heading, .field-label-row, .action-card, .source-overlay, .run-status, +.result-heading, .result-meta { + display: flex; + align-items: center; +} + +.brand { gap: 12px; min-width: 0; } +.brand-mark { + width: 43px; + height: 43px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid rgba(174, 141, 255, 0.42); + border-radius: 14px; + color: #d7cbff; + background: linear-gradient(145deg, rgba(139, 124, 255, 0.3), rgba(66, 196, 215, 0.08)); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.1), 0 10px 28px rgba(80, 54, 180, 0.2); +} +.brand-mark svg { width: 29px; height: 29px; } +.brand-line { gap: 9px; } +.brand h1 { margin: 0; font-size: 18px; letter-spacing: -0.02em; } +.brand p { margin: 4px 0 0; color: var(--muted); font-size: 12px; } +.api-badge { + padding: 3px 6px; + border: 1px solid rgba(110, 231, 242, 0.25); + border-radius: 999px; + color: var(--cyan); + background: rgba(110, 231, 242, 0.08); + font-size: 8px; + font-weight: 800; + letter-spacing: 0.1em; +} + +.topbar-meta { justify-content: flex-end; gap: 7px; flex-wrap: wrap; } +.keyword { + padding: 5px 9px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted-strong); + background: rgba(255, 255, 255, 0.035); + font-size: 10px; +} +.connection-pill { + min-width: 88px; + margin-left: 4px; + padding: 6px 9px; + display: flex; + align-items: center; + gap: 6px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + background: rgba(255, 255, 255, 0.03); + font-size: 10px; +} +.connection-dot, .run-status-dot { + width: 6px; + height: 6px; + flex: 0 0 auto; + border-radius: 50%; + background: #65697a; +} +.connection-pill.is-connected { color: #bff7df; border-color: rgba(99, 223, 170, 0.24); } +.connection-pill.is-connected .connection-dot { background: var(--success); box-shadow: 0 0 10px rgba(99, 223, 170, 0.7); } + +.workspace { + width: 100%; + max-width: 1360px; + margin: 0 auto; + padding: 16px 18px 10px; + display: grid; + grid-template-columns: minmax(320px, 0.95fr) minmax(400px, 1.15fr); + gap: 14px; + flex: 1; +} + +.panel { + min-width: 0; + border: 1px solid var(--line); + border-radius: 18px; + background: linear-gradient(145deg, rgba(24, 26, 37, 0.96), rgba(16, 18, 26, 0.96)); + box-shadow: inset 0 1px rgba(255, 255, 255, 0.035), 0 18px 50px rgba(0, 0, 0, 0.18); +} +.source-panel, .controls-panel { padding: 16px; } +.panel-heading { justify-content: space-between; gap: 12px; min-height: 28px; } +.panel-heading > div { gap: 8px; } +.panel-heading h2 { margin: 0; font-size: 14px; letter-spacing: -0.01em; } +.step-number { + width: 25px; + height: 20px; + display: inline-grid; + place-items: center; + border-radius: 7px; + color: #cfc6ff; + background: var(--accent-soft); + font-size: 9px; + font-weight: 800; + letter-spacing: 0.05em; +} + +.icon-button { + width: 30px; + height: 30px; + display: grid; + place-items: center; + border: 1px solid var(--line); + border-radius: 9px; + color: var(--muted-strong); + background: rgba(255, 255, 255, 0.035); +} +.icon-button:hover:not(:disabled) { color: var(--text); border-color: var(--line-strong); background: rgba(255, 255, 255, 0.07); } +.icon-button:disabled { opacity: 0.4; } +.icon-button svg { width: 15px; height: 15px; } + +.source-stage { + position: relative; + min-height: 276px; + margin-top: 13px; + overflow: hidden; + display: grid; + place-items: center; + border: 1px solid rgba(160, 151, 255, 0.16); + border-radius: 14px; + background: + radial-gradient(circle at 50% 42%, rgba(128, 110, 255, 0.18), transparent 43%), + linear-gradient(145deg, #121520, #0d0f17); +} +.source-stage.has-image { background: #08090d; } +.source-image { width: 100%; height: 100%; min-height: 276px; max-height: 350px; object-fit: contain; } +.source-grid { + position: absolute; + inset: 0; + opacity: 0.17; + background-image: + linear-gradient(rgba(255, 255, 255, 0.09) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.09) 1px, transparent 1px); + background-size: 28px 28px; + mask-image: radial-gradient(circle, black 5%, transparent 75%); +} +.source-stage.has-image .source-grid { opacity: 0; } +.empty-source { position: relative; z-index: 1; padding: 28px; text-align: center; } +.empty-camera { + width: 66px; + height: 66px; + margin: 0 auto 12px; + display: grid; + place-items: center; + border: 1px solid rgba(139, 124, 255, 0.24); + border-radius: 22px; + color: #beb1ff; + background: rgba(139, 124, 255, 0.09); +} +.empty-camera svg { width: 43px; height: 43px; } +.empty-source strong { display: block; font-size: 14px; } +.empty-source p { margin: 7px 0 8px; color: var(--muted-strong); font-size: 11px; } +.empty-source > span:last-child { color: #6f7486; font-size: 9px; letter-spacing: 0.04em; } +.source-overlay { position: absolute; inset: auto 10px 10px; justify-content: space-between; pointer-events: none; } +.source-badge, .source-size { + padding: 5px 7px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 7px; + color: #ececf4; + background: rgba(7, 8, 12, 0.72); + backdrop-filter: blur(8px); + font-size: 8px; + font-weight: 700; + letter-spacing: 0.08em; +} +.source-size { color: #c7cad5; font-weight: 500; letter-spacing: 0; } +.loading-overlay { + position: absolute; + inset: 0; + z-index: 4; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + color: var(--muted-strong); + background: rgba(8, 9, 13, 0.76); + backdrop-filter: blur(6px); + font-size: 11px; +} +.spinner { + width: 15px; + height: 15px; + border: 2px solid rgba(255, 255, 255, 0.16); + border-top-color: var(--accent-strong); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } + +.select-field, .tool-field { display: grid; gap: 6px; } +.select-field { margin-top: 12px; color: var(--muted); font-size: 10px; } +.select-control { position: relative; display: block; } +.select-control select { + width: 100%; + height: 35px; + padding: 0 34px 0 10px; + appearance: none; + border: 1px solid var(--line); + border-radius: 9px; + outline: none; + background: #10121a; + font-size: 11px; +} +.select-control select:focus { border-color: rgba(139, 124, 255, 0.65); } +.select-control select:disabled { color: #686d7d; opacity: 1; } +.select-control svg { position: absolute; right: 10px; top: 10px; width: 15px; height: 15px; pointer-events: none; color: var(--muted); } +.source-help, .tool-help { margin: 0; color: #74798a; font-size: 9px; line-height: 1.45; } +.source-help { margin-top: 10px; } + +.selection-count, .model-count { color: var(--muted); font-size: 10px; } +.field-group { margin: 13px 0 0; padding: 0; border: 0; } +.field-group legend, .field-label-row { width: 100%; color: var(--muted-strong); font-size: 10px; font-weight: 650; } +.field-label-row { justify-content: space-between; } +.tool-select-control { margin-top: 2px; } +.segmented { margin-top: 8px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; } +.segment-button, .preset-button { + border: 1px solid var(--line); + color: var(--muted-strong); + background: rgba(255, 255, 255, 0.025); + transition: 140ms ease; +} +.segment-button { height: 32px; border-radius: 9px; font-size: 10px; } +.segment-button:hover:not(:disabled), .preset-button:hover:not(:disabled) { border-color: var(--line-strong); background: rgba(255, 255, 255, 0.055); } +.segment-button:disabled, .preset-button:disabled { opacity: 0.55; } +.segment-button.is-active, .preset-button.is-active { + color: #e9e5ff; + border-color: rgba(139, 124, 255, 0.54); + background: linear-gradient(145deg, rgba(139, 124, 255, 0.22), rgba(139, 124, 255, 0.08)); +} +.preset-grid { margin-top: 8px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; } +.preset-button { + min-height: 48px; + padding: 7px 6px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 4px; + border-radius: 10px; + text-align: left; +} +.preset-button > span:first-child { display: grid; gap: 2px; } +.preset-button strong { font-size: 10px; font-weight: 650; } +.preset-button small { color: #74798a; font-size: 8px; } +.preset-check { + width: 14px; + height: 14px; + display: grid; + place-items: center; + flex: 0 0 auto; + border: 1px solid #53586b; + border-radius: 5px; + color: transparent; + font-size: 9px; +} +.preset-button.is-active .preset-check { color: white; border-color: var(--accent); background: var(--accent); } + +.notes-field { display: grid; gap: 8px; } +.field-label-row em { margin-left: 4px; color: #6d7282; font-size: 9px; font-style: normal; font-weight: 500; } +.field-label-row output { color: #6d7282; font-size: 9px; font-weight: 500; } +textarea { + width: 100%; + min-height: 62px; + padding: 9px 10px; + resize: vertical; + border: 1px solid var(--line); + border-radius: 10px; + outline: none; + color: #d9dbe4; + background: #10121a; + font-size: 10px; + line-height: 1.55; +} +textarea::placeholder { color: #5d6272; } +textarea:focus { border-color: rgba(139, 124, 255, 0.58); box-shadow: 0 0 0 3px rgba(139, 124, 255, 0.08); } +textarea:disabled { opacity: 0.58; } + +.action-card { + margin-top: 13px; + padding: 10px 10px 10px 12px; + justify-content: space-between; + gap: 12px; + border: 1px solid rgba(139, 124, 255, 0.15); + border-radius: 12px; + background: linear-gradient(100deg, rgba(139, 124, 255, 0.11), rgba(110, 231, 242, 0.035)); +} +.action-copy { min-width: 0; display: grid; gap: 3px; } +.action-copy strong { font-size: 10px; } +.action-copy span { color: var(--muted); font-size: 8px; line-height: 1.35; } +.action-copy .capability-boundary { color: #c7a96f; font-size: 7.5px; } +.generate-button { + min-height: 36px; + padding: 0 13px; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; + flex: 0 0 auto; + border: 1px solid rgba(210, 196, 255, 0.36); + border-radius: 10px; + color: white; + background: linear-gradient(135deg, #7967f6, #9a70e7); + box-shadow: 0 7px 20px rgba(105, 78, 225, 0.22), inset 0 1px rgba(255, 255, 255, 0.2); + font-size: 10px; + font-weight: 700; +} +.generate-button:hover:not(:disabled) { filter: brightness(1.08); transform: translateY(-1px); } +.generate-button:disabled { opacity: 0.38; box-shadow: none; } +.generate-button svg { width: 15px; height: 15px; } + +.results-panel { grid-column: 1 / -1; padding: 13px 16px 15px; } +.results-heading { justify-content: space-between; gap: 12px; } +.run-status { + padding: 5px 8px; + gap: 6px; + border: 1px solid var(--line); + border-radius: 999px; + color: var(--muted); + background: rgba(255, 255, 255, 0.025); + font-size: 9px; +} +.run-status.is-running .run-status-dot { background: var(--accent-strong); animation: pulse 1.2s ease-in-out infinite; } +.run-status.is-success { color: #bff7df; border-color: rgba(99, 223, 170, 0.2); } +.run-status.is-success .run-status-dot { background: var(--success); } +.run-status.is-warning { color: #ffe0a6; border-color: rgba(245, 189, 104, 0.2); } +.run-status.is-warning .run-status-dot { background: var(--warning); } +.run-status.is-error { color: #ffbac3; border-color: rgba(255, 125, 142, 0.2); } +.run-status.is-error .run-status-dot { background: var(--danger); } +@keyframes pulse { 50% { opacity: 0.35; transform: scale(0.72); } } + +.results-grid { margin-top: 11px; display: grid; grid-template-columns: minmax(0, 1fr); gap: 8px; } +.result-card { + min-height: 108px; + padding: 10px; + display: grid; + align-content: space-between; + gap: 10px; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 12px; + background: + radial-gradient(circle at 90% 0%, rgba(139, 124, 255, 0.12), transparent 42%), + rgba(255, 255, 255, 0.025); +} +.result-card.is-running { border-color: rgba(139, 124, 255, 0.42); } +.result-card.is-success { border-color: rgba(99, 223, 170, 0.25); } +.result-card.is-failed { border-color: rgba(255, 125, 142, 0.25); } +.result-heading { justify-content: space-between; gap: 8px; } +.result-angle { display: grid; gap: 2px; } +.result-angle strong { font-size: 11px; } +.result-angle small { color: #74798a; font-size: 8px; } +.result-icon { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 9px; + color: #cfc6ff; + background: var(--accent-soft); + font-size: 14px; +} +.result-copy { display: grid; gap: 4px; } +.result-copy span { color: var(--muted-strong); font-size: 9px; line-height: 1.35; } +.result-copy code { + max-width: 100%; + overflow: hidden; + color: #818699; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 7px; + text-overflow: ellipsis; + white-space: nowrap; +} +.result-meta { justify-content: space-between; gap: 8px; color: #74798a; font-size: 8px; } +.result-status.is-running { color: #cfc6ff; } +.result-status.is-success { color: var(--success); } +.result-status.is-failed { color: var(--danger); } + +.message-panel { + margin-top: 9px; + padding: 9px 11px; + border: 1px solid rgba(245, 189, 104, 0.18); + border-radius: 10px; + background: rgba(245, 189, 104, 0.06); +} +.message-panel.is-error { border-color: rgba(255, 125, 142, 0.2); background: rgba(255, 125, 142, 0.06); } +.message-panel strong { font-size: 9px; } +.message-panel p { margin: 4px 0 0; color: var(--muted-strong); font-size: 9px; line-height: 1.45; } + +.footer-note { + min-height: 30px; + padding: 5px 20px 8px; + display: flex; + justify-content: space-between; + gap: 16px; + color: #666b7a; + font-size: 8px; +} +.toast { + position: fixed; + z-index: 20; + right: 18px; + bottom: 18px; + max-width: min(420px, calc(100vw - 36px)); + padding: 10px 12px; + border: 1px solid rgba(139, 124, 255, 0.3); + border-radius: 10px; + color: #e9e5ff; + background: rgba(27, 24, 43, 0.96); + box-shadow: 0 14px 38px rgba(0, 0, 0, 0.36); + font-size: 10px; + line-height: 1.45; +} +.toast.is-warning { color: #ffe0a6; border-color: rgba(245, 189, 104, 0.34); background: rgba(47, 36, 21, 0.96); } +.toast.is-error { color: #ffbac3; border-color: rgba(255, 125, 142, 0.35); background: rgba(49, 25, 31, 0.96); } + +@media (max-width: 820px) { + .topbar { align-items: flex-start; } + .topbar-meta { display: none; } + .workspace { grid-template-columns: 1fr; } + .results-panel { grid-column: auto; } + .preset-grid { grid-template-columns: repeat(2, 1fr); } + .action-card { align-items: stretch; flex-direction: column; } + .generate-button { width: 100%; } + .footer-note { flex-direction: column; gap: 3px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition: none !important; } +} diff --git a/packages/plugins/multi-angle/package/index.html b/packages/plugins/multi-angle/package/index.html new file mode 100644 index 0000000..80a2500 --- /dev/null +++ b/packages/plugins/multi-angle/package/index.html @@ -0,0 +1,175 @@ + + +
+ + +从同一张参考图生成一张一致的多视角宫格图
+从 Canvas 图片节点连线到“多角度”节点
+ JPEG · PNG · WebP · ≤ 16 MiB +连接范围由当前 Project、Canvas 与本节点共同约束。
+