feat(canvas): 画布节点 - 恢复批量创作表与转换能力
This commit is contained in:
202
web/src/services/depth-runtime.ts
Normal file
202
web/src/services/depth-runtime.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
|
||||
import type { LocalRuntimeTransport } from "@/services/local-runtime";
|
||||
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
|
||||
|
||||
const MAX_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
||||
const DATA_URL_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=_-]+)$/;
|
||||
|
||||
export type LocalDepthResult = {
|
||||
blob: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
modelId: string;
|
||||
device: "cpu";
|
||||
};
|
||||
|
||||
export type LocalDepthRuntimeStatus = {
|
||||
ok: true;
|
||||
module: "depth-estimation";
|
||||
apiVersion: 1;
|
||||
ready: boolean;
|
||||
configured: boolean;
|
||||
modelCached: boolean;
|
||||
loaded: boolean;
|
||||
device: "cpu";
|
||||
modelId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function runLocalDepthEstimation(sourceUrl: string, signal?: AbortSignal): Promise<LocalDepthResult> {
|
||||
const dataUrl = await sourceToDataUrl(sourceUrl, signal);
|
||||
const value = await requestJson<unknown>("/depth-estimation/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ schemaVersion: 1, operation: "depth", dataUrl }),
|
||||
headers: { "content-type": "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "depth-estimation" || value.apiVersion !== 1 || !isRecord(value.result)) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度结果响应无效");
|
||||
}
|
||||
return parseResult(value.result);
|
||||
}
|
||||
|
||||
export async function readLocalDepthRuntimeStatus(signal?: AbortSignal) {
|
||||
return parseStatus(await requestJson<unknown>("/depth-estimation/status", { method: "GET", signal }));
|
||||
}
|
||||
|
||||
async function sourceToDataUrl(sourceUrl: string, signal?: AbortSignal) {
|
||||
if (sourceUrl.startsWith("data:")) {
|
||||
const match = DATA_URL_PATTERN.exec(sourceUrl);
|
||||
if (!match) throw new LocalRuntimeClientError("depth_input_invalid", "深度转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
const bytes = bytesFromBase64(match[2]!);
|
||||
assertImageSize(bytes);
|
||||
return `data:${match[1]};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(sourceUrl, { credentials: "include", redirect: "error", cache: "no-store", signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
throw new LocalRuntimeClientError("depth_input_invalid", "深度转换输入图片无法读取");
|
||||
}
|
||||
if (!response.ok) throw new LocalRuntimeClientError("depth_input_invalid", "深度转换输入图片无法读取", response.status);
|
||||
const mimeType = imageMime(response.headers.get("content-type") || response.type);
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) {
|
||||
throw new LocalRuntimeClientError("depth_input_invalid", "深度转换图片不能超过 12MB", 400);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
assertImageSize(bytes);
|
||||
return `data:${mimeType};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
|
||||
async function requestJson<T = unknown>(path: string, init: RequestInit = {}) {
|
||||
const { response, body: responseBody } = await requestRuntimeResponse(path, init);
|
||||
const body = responseBody === undefined ? await parseJsonResponse(response) : responseBody;
|
||||
if (!response.ok) {
|
||||
const code = isRecord(body) && typeof body.code === "string" ? body.code : "depth_runtime_unavailable";
|
||||
const message = isRecord(body) && typeof body.message === "string" ? body.message : "本机深度运行时不可用";
|
||||
throw new LocalRuntimeClientError(code, message, response.status);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
async function requestRuntimeResponse(path: string, init: RequestInit = {}) {
|
||||
let refreshed = false;
|
||||
while (true) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (await ensureTransport()).request(path, init);
|
||||
} catch (error) {
|
||||
if (refreshed || !isSessionRefreshError(error)) throw error;
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (!refreshed && response.status === 401) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const body = await parseJsonResponse(response);
|
||||
if (!refreshed && shouldRefreshSession(response, body)) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
return { response, body };
|
||||
}
|
||||
return { response };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTransport(): Promise<LocalRuntimeTransport> {
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") await state.connect();
|
||||
const current = useLocalRuntimeStore.getState();
|
||||
if (current.connection !== "connected") throw new LocalRuntimeClientError("depth_runtime_unavailable", current.error || "本机深度运行时不可用");
|
||||
return getLocalRuntimeSessionClient();
|
||||
}
|
||||
|
||||
async function reconnectTransport() {
|
||||
const client = getLocalRuntimeSessionClient();
|
||||
client.revokeLocalSession();
|
||||
await useLocalRuntimeStore.getState().connect();
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") throw new LocalRuntimeClientError("depth_runtime_unavailable", state.error || "本机深度运行时不可用");
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response: Response) {
|
||||
const text = await boundedText(response, MAX_RESPONSE_BYTES);
|
||||
try {
|
||||
return text ? JSON.parse(text) as unknown : {};
|
||||
} catch {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度响应无效", response.status);
|
||||
}
|
||||
}
|
||||
|
||||
async function boundedText(response: Response, limit: number) {
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度响应过大", response.status);
|
||||
const text = await response.text();
|
||||
if (text.length > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度响应过大", response.status);
|
||||
return text;
|
||||
}
|
||||
|
||||
function parseResult(value: Record<string, unknown>): LocalDepthResult {
|
||||
if (value.mimeType !== "image/png" || !isPositiveInteger(value.width) || !isPositiveInteger(value.height) || typeof value.modelId !== "string" || value.device !== "cpu" || typeof value.pngBase64 !== "string") {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度结果无效");
|
||||
}
|
||||
const bytes = bytesFromBase64(value.pngBase64, "runtime_response_invalid", "本机深度结果无效");
|
||||
if (!bytes.length || bytes.length > MAX_RESPONSE_BYTES) throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度结果过大");
|
||||
return { blob: new Blob([bytes], { type: "image/png" }), width: value.width, height: value.height, modelId: value.modelId, device: "cpu" };
|
||||
}
|
||||
|
||||
function parseStatus(value: unknown): LocalDepthRuntimeStatus {
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "depth-estimation" || value.apiVersion !== 1 || typeof value.ready !== "boolean" || typeof value.configured !== "boolean" || typeof value.modelCached !== "boolean" || typeof value.loaded !== "boolean" || value.device !== "cpu" || typeof value.modelId !== "string" || (value.reason !== undefined && typeof value.reason !== "string")) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机深度状态无效");
|
||||
}
|
||||
return value as LocalDepthRuntimeStatus;
|
||||
}
|
||||
|
||||
function imageMime(value: string): "image/jpeg" | "image/png" | "image/webp" {
|
||||
const mime = value.startsWith("image/") ? value.split(";", 1)[0] : "";
|
||||
if (mime === "image/jpeg" || mime === "image/png" || mime === "image/webp") return mime;
|
||||
throw new LocalRuntimeClientError("depth_input_invalid", "深度转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
}
|
||||
|
||||
function assertImageSize(bytes: Uint8Array) {
|
||||
if (!bytes.byteLength || bytes.byteLength > MAX_IMAGE_BYTES) throw new LocalRuntimeClientError("depth_input_invalid", "深度转换图片不能超过 12MB", 400);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesFromBase64(value: string, code = "depth_input_invalid", message = "深度转换输入图片无效") {
|
||||
let binary: string;
|
||||
try { binary = atob(value.replaceAll("-", "+").replaceAll("_", "/")); } catch { throw new LocalRuntimeClientError(code, message, code === "depth_input_invalid" ? 400 : 0); }
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function isSessionRefreshError(error: unknown): error is LocalRuntimeClientError {
|
||||
return error instanceof LocalRuntimeClientError && ["session_required", "session_invalid", "scope_denied"].includes(error.code);
|
||||
}
|
||||
|
||||
function shouldRefreshSession(response: Response, body: unknown) {
|
||||
return response.status === 401 || (response.status === 403 && isRecord(body) && body.code === "scope_denied");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
202
web/src/services/lineart-runtime.ts
Normal file
202
web/src/services/lineart-runtime.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
|
||||
import type { LocalRuntimeTransport } from "@/services/local-runtime";
|
||||
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
|
||||
|
||||
const MAX_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
||||
const DATA_URL_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=_-]+)$/;
|
||||
|
||||
export type LocalLineartResult = {
|
||||
blob: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
modelId: string;
|
||||
device: "cpu";
|
||||
};
|
||||
|
||||
export type LocalLineartRuntimeStatus = {
|
||||
ok: true;
|
||||
module: "lineart-estimation";
|
||||
apiVersion: 1;
|
||||
ready: boolean;
|
||||
configured: boolean;
|
||||
modelCached: boolean;
|
||||
loaded: boolean;
|
||||
device: "cpu";
|
||||
modelId: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function runLocalLineartEstimation(sourceUrl: string, signal?: AbortSignal): Promise<LocalLineartResult> {
|
||||
const dataUrl = await sourceToDataUrl(sourceUrl, signal);
|
||||
const value = await requestJson<unknown>("/lineart-estimation/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ schemaVersion: 1, operation: "lineart", dataUrl }),
|
||||
headers: { "content-type": "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "lineart-estimation" || value.apiVersion !== 1 || !isRecord(value.result)) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿结果响应无效");
|
||||
}
|
||||
return parseResult(value.result);
|
||||
}
|
||||
|
||||
export async function readLocalLineartRuntimeStatus(signal?: AbortSignal) {
|
||||
return parseStatus(await requestJson<unknown>("/lineart-estimation/status", { method: "GET", signal }));
|
||||
}
|
||||
|
||||
async function sourceToDataUrl(sourceUrl: string, signal?: AbortSignal) {
|
||||
if (sourceUrl.startsWith("data:")) {
|
||||
const match = DATA_URL_PATTERN.exec(sourceUrl);
|
||||
if (!match) throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
const bytes = bytesFromBase64(match[2]!);
|
||||
assertImageSize(bytes);
|
||||
return `data:${match[1]};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(sourceUrl, { credentials: "include", redirect: "error", cache: "no-store", signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿输入图片无法读取");
|
||||
}
|
||||
if (!response.ok) throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿输入图片无法读取", response.status);
|
||||
const mimeType = imageMime(response.headers.get("content-type") || response.type);
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) {
|
||||
throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿图片不能超过 12MB", 400);
|
||||
}
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
assertImageSize(bytes);
|
||||
return `data:${mimeType};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
|
||||
async function requestJson<T = unknown>(path: string, init: RequestInit = {}) {
|
||||
const { response, body: responseBody } = await requestRuntimeResponse(path, init);
|
||||
const body = responseBody === undefined ? await parseJsonResponse(response) : responseBody;
|
||||
if (!response.ok) {
|
||||
const code = isRecord(body) && typeof body.code === "string" ? body.code : "lineart_runtime_unavailable";
|
||||
const message = isRecord(body) && typeof body.message === "string" ? body.message : "本机 AI 线稿运行时不可用";
|
||||
throw new LocalRuntimeClientError(code, message, response.status);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
async function requestRuntimeResponse(path: string, init: RequestInit = {}) {
|
||||
let refreshed = false;
|
||||
while (true) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (await ensureTransport()).request(path, init);
|
||||
} catch (error) {
|
||||
if (refreshed || !isSessionRefreshError(error)) throw error;
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (!refreshed && response.status === 401) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const body = await parseJsonResponse(response);
|
||||
if (!refreshed && shouldRefreshSession(response, body)) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
return { response, body };
|
||||
}
|
||||
return { response };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTransport(): Promise<LocalRuntimeTransport> {
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") await state.connect();
|
||||
const current = useLocalRuntimeStore.getState();
|
||||
if (current.connection !== "connected") throw new LocalRuntimeClientError("lineart_runtime_unavailable", current.error || "本机 AI 线稿运行时不可用");
|
||||
return getLocalRuntimeSessionClient();
|
||||
}
|
||||
|
||||
async function reconnectTransport() {
|
||||
const client = getLocalRuntimeSessionClient();
|
||||
client.revokeLocalSession();
|
||||
await useLocalRuntimeStore.getState().connect();
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") throw new LocalRuntimeClientError("lineart_runtime_unavailable", state.error || "本机 AI 线稿运行时不可用");
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response: Response) {
|
||||
const text = await boundedText(response, MAX_RESPONSE_BYTES);
|
||||
try {
|
||||
return text ? JSON.parse(text) as unknown : {};
|
||||
} catch {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿响应无效", response.status);
|
||||
}
|
||||
}
|
||||
|
||||
async function boundedText(response: Response, limit: number) {
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿响应过大", response.status);
|
||||
const text = await response.text();
|
||||
if (text.length > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿响应过大", response.status);
|
||||
return text;
|
||||
}
|
||||
|
||||
function parseResult(value: Record<string, unknown>): LocalLineartResult {
|
||||
if (value.mimeType !== "image/png" || !isPositiveInteger(value.width) || !isPositiveInteger(value.height) || typeof value.modelId !== "string" || value.device !== "cpu" || typeof value.pngBase64 !== "string") {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿结果无效");
|
||||
}
|
||||
const bytes = bytesFromBase64(value.pngBase64, "runtime_response_invalid", "本机 AI 线稿结果无效");
|
||||
if (!bytes.length || bytes.length > MAX_RESPONSE_BYTES) throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿结果过大");
|
||||
return { blob: new Blob([bytes], { type: "image/png" }), width: value.width, height: value.height, modelId: value.modelId, device: "cpu" };
|
||||
}
|
||||
|
||||
function parseStatus(value: unknown): LocalLineartRuntimeStatus {
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "lineart-estimation" || value.apiVersion !== 1 || typeof value.ready !== "boolean" || typeof value.configured !== "boolean" || typeof value.modelCached !== "boolean" || typeof value.loaded !== "boolean" || value.device !== "cpu" || typeof value.modelId !== "string" || (value.reason !== undefined && typeof value.reason !== "string")) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机 AI 线稿状态无效");
|
||||
}
|
||||
return value as LocalLineartRuntimeStatus;
|
||||
}
|
||||
|
||||
function imageMime(value: string): "image/jpeg" | "image/png" | "image/webp" {
|
||||
const mime = value.startsWith("image/") ? value.split(";", 1)[0] : "";
|
||||
if (mime === "image/jpeg" || mime === "image/png" || mime === "image/webp") return mime;
|
||||
throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
}
|
||||
|
||||
function assertImageSize(bytes: Uint8Array) {
|
||||
if (!bytes.byteLength || bytes.byteLength > MAX_IMAGE_BYTES) throw new LocalRuntimeClientError("lineart_input_invalid", "AI 线稿图片不能超过 12MB", 400);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesFromBase64(value: string, code = "lineart_input_invalid", message = "AI 线稿输入图片无效") {
|
||||
let binary: string;
|
||||
try { binary = atob(value.replaceAll("-", "+").replaceAll("_", "/")); } catch { throw new LocalRuntimeClientError(code, message, code === "lineart_input_invalid" ? 400 : 0); }
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function isSessionRefreshError(error: unknown): error is LocalRuntimeClientError {
|
||||
return error instanceof LocalRuntimeClientError && ["session_required", "session_invalid", "scope_denied"].includes(error.code);
|
||||
}
|
||||
|
||||
function shouldRefreshSession(response: Response, body: unknown) {
|
||||
return response.status === 401 || (response.status === 403 && isRecord(body) && body.code === "scope_denied");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
463
web/src/services/local-runtime-session.ts
Normal file
463
web/src/services/local-runtime-session.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
import { openDB } from "idb";
|
||||
import { canonicalize } from "json-canonicalize";
|
||||
|
||||
const configuredRuntimeEndpoint = typeof import.meta.env === "object" ? import.meta.env.VITE_FRAMEFIELD_LOCAL_RUNTIME_ENDPOINT : undefined;
|
||||
export const LOCAL_RUNTIME_ENDPOINT = resolveLocalRuntimeEndpoint(configuredRuntimeEndpoint);
|
||||
const KEY_DATABASE = "framefield-local-runtime";
|
||||
const KEY_STORE = "browser-keys";
|
||||
const KEY_RECORD_ID = "default";
|
||||
|
||||
export function resolveLocalRuntimeEndpoint(value: string | undefined) {
|
||||
if (value === undefined) return "http://127.0.0.1:17371";
|
||||
if (!value || value !== value.trim() || value.includes(",")) {
|
||||
throw new Error("Local Runtime endpoint must be one exact loopback origin");
|
||||
}
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.pathname !== "/" || url.username || url.password || url.search || url.hash || url.origin !== value) {
|
||||
throw new Error("Local Runtime endpoint must be one exact loopback origin");
|
||||
}
|
||||
return url.origin;
|
||||
}
|
||||
|
||||
export type RuntimeBrowserKeyRecord = {
|
||||
id: typeof KEY_RECORD_ID;
|
||||
privateKey: CryptoKey;
|
||||
publicKeyJwk: JsonWebKey;
|
||||
keyId: string;
|
||||
registered: boolean;
|
||||
};
|
||||
|
||||
export type RuntimeBrowserKeyStore = {
|
||||
load(): Promise<RuntimeBrowserKeyRecord | undefined>;
|
||||
save(record: RuntimeBrowserKeyRecord): Promise<void>;
|
||||
clear(): Promise<void>;
|
||||
};
|
||||
|
||||
export type RuntimePublicSession = {
|
||||
sessionId: string;
|
||||
keyId: string;
|
||||
scopes: string[];
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
export type LocalRuntimeConnection = { state: "connected"; session: RuntimePublicSession; runtimeVersion: number } | { state: "origin_not_trusted"; runtimeVersion: number };
|
||||
|
||||
type RuntimeInfo = {
|
||||
runtime: "framefield-local-runtime";
|
||||
apiVersion: 2;
|
||||
protocolVersion: "framefield-runtime-session-v1";
|
||||
runtimeInstanceId: string;
|
||||
originTrusted: boolean;
|
||||
};
|
||||
|
||||
type RuntimeChallenge = {
|
||||
state: "challenge";
|
||||
challengeId: string;
|
||||
nonce: string;
|
||||
runtimeInstanceId: string;
|
||||
expiresAt: string;
|
||||
keyId: string;
|
||||
};
|
||||
|
||||
type LocalRuntimeSessionClientOptions = {
|
||||
origin?: string;
|
||||
keyStore?: RuntimeBrowserKeyStore;
|
||||
fetch?: typeof fetch;
|
||||
crypto?: Crypto;
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export class LocalRuntimeClientError extends Error {
|
||||
constructor(
|
||||
readonly code: string,
|
||||
message: string,
|
||||
readonly status = 0,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "LocalRuntimeClientError";
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalRuntimeSessionClient {
|
||||
private readonly origin: string;
|
||||
private readonly keyStore: RuntimeBrowserKeyStore;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly cryptoImpl: Crypto;
|
||||
private readonly now: () => number;
|
||||
private session?: RuntimePublicSession;
|
||||
private runtimeInstanceId?: string;
|
||||
private pending?: RuntimeChallenge;
|
||||
private connectPromise?: Promise<LocalRuntimeConnection>;
|
||||
|
||||
constructor(options: LocalRuntimeSessionClientOptions = {}) {
|
||||
this.origin = exactOrigin(options.origin ?? globalThis.location?.origin ?? "");
|
||||
this.keyStore = options.keyStore ?? createIndexedDbRuntimeKeyStore();
|
||||
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
||||
this.cryptoImpl = options.crypto ?? globalThis.crypto;
|
||||
this.now = options.now ?? Date.now;
|
||||
if (!this.cryptoImpl?.subtle || typeof this.cryptoImpl.getRandomValues !== "function") {
|
||||
throw new LocalRuntimeClientError("webcrypto_unavailable", "浏览器不支持本机安全连接");
|
||||
}
|
||||
}
|
||||
|
||||
currentSession() {
|
||||
return this.session ? { ...this.session, scopes: [...this.session.scopes] } : undefined;
|
||||
}
|
||||
|
||||
connect(signal?: AbortSignal) {
|
||||
if (this.session && Date.parse(this.session.expiresAt) > this.now()) {
|
||||
return Promise.resolve<LocalRuntimeConnection>({
|
||||
state: "connected",
|
||||
session: this.currentSession()!,
|
||||
runtimeVersion: 2,
|
||||
});
|
||||
}
|
||||
this.connectPromise ??= this.connectOnce(signal).finally(() => {
|
||||
this.connectPromise = undefined;
|
||||
});
|
||||
return this.connectPromise;
|
||||
}
|
||||
|
||||
async request(pathAndQuery: string, init: RequestInit = {}) {
|
||||
const session = this.session;
|
||||
if (!session || Date.parse(session.expiresAt) <= this.now()) {
|
||||
this.session = undefined;
|
||||
throw new LocalRuntimeClientError("session_required", "本机会话尚未建立", 401);
|
||||
}
|
||||
const url = exactRuntimeUrl(pathAndQuery);
|
||||
const method = String(init.method ?? "GET").toUpperCase();
|
||||
const bodyBytes = requestBodyBytes(init.body);
|
||||
const headers = new Headers(init.headers);
|
||||
if (headers.has("authorization") || headers.has("x-canvas-agent-token")) {
|
||||
throw new LocalRuntimeClientError("legacy_bearer_rejected", "正常本机请求不能携带旧版凭据");
|
||||
}
|
||||
const timestamp = this.now();
|
||||
const requestNonce = randomBase64Url(this.cryptoImpl, 16);
|
||||
const lastEventId = headers.get("last-event-id");
|
||||
const payload = {
|
||||
protocol: "framefield-runtime-request-v1",
|
||||
sessionId: session.sessionId,
|
||||
keyId: session.keyId,
|
||||
method,
|
||||
pathAndQuery: `${url.pathname}${url.search}`,
|
||||
bodySha256: await sha256Base64Url(this.cryptoImpl, bodyBytes),
|
||||
lastEventId,
|
||||
origin: this.origin,
|
||||
endpoint: LOCAL_RUNTIME_ENDPOINT,
|
||||
runtimeInstanceId: this.runtimeInstanceId!,
|
||||
requestNonce,
|
||||
timestamp,
|
||||
sessionExpiresAt: session.expiresAt,
|
||||
};
|
||||
const key = await this.requireKey();
|
||||
const proof = await signP1363(this.cryptoImpl, key.privateKey, canonicalize(payload));
|
||||
headers.set("X-Framefield-Runtime-Session", session.sessionId);
|
||||
headers.set("X-Framefield-Runtime-Timestamp", String(timestamp));
|
||||
headers.set("X-Framefield-Runtime-Nonce", requestNonce);
|
||||
headers.set("X-Framefield-Runtime-Proof", proof);
|
||||
const response = await this.fetchImpl(url, {
|
||||
...init,
|
||||
method,
|
||||
headers,
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
});
|
||||
if (response.status === 401) this.session = undefined;
|
||||
return response;
|
||||
}
|
||||
|
||||
revokeLocalSession() {
|
||||
this.session = undefined;
|
||||
this.pending = undefined;
|
||||
}
|
||||
|
||||
private async connectOnce(signal?: AbortSignal): Promise<LocalRuntimeConnection> {
|
||||
const info = await this.readInfo(signal);
|
||||
if (!info.originTrusted) {
|
||||
this.session = undefined;
|
||||
this.pending = undefined;
|
||||
return { state: "origin_not_trusted", runtimeVersion: info.apiVersion };
|
||||
}
|
||||
if (this.runtimeInstanceId && this.runtimeInstanceId !== info.runtimeInstanceId) {
|
||||
this.session = undefined;
|
||||
this.pending = undefined;
|
||||
}
|
||||
this.runtimeInstanceId = info.runtimeInstanceId;
|
||||
const key = await this.requireKey();
|
||||
let challenge = this.pending;
|
||||
if (!challenge || Date.parse(challenge.expiresAt) <= this.now()) {
|
||||
challenge = await this.createChallenge(key, signal);
|
||||
this.pending = challenge;
|
||||
}
|
||||
if (challenge.runtimeInstanceId !== info.runtimeInstanceId || challenge.keyId !== key.keyId) {
|
||||
this.pending = undefined;
|
||||
throw new LocalRuntimeClientError("challenge_invalid", "本机会话挑战无效");
|
||||
}
|
||||
const signature = await signP1363(
|
||||
this.cryptoImpl,
|
||||
key.privateKey,
|
||||
canonicalize({
|
||||
protocol: "framefield-runtime-session-v1",
|
||||
challengeId: challenge.challengeId,
|
||||
nonce: challenge.nonce,
|
||||
origin: this.origin,
|
||||
endpoint: LOCAL_RUNTIME_ENDPOINT,
|
||||
runtimeInstanceId: challenge.runtimeInstanceId,
|
||||
expiresAt: challenge.expiresAt,
|
||||
}),
|
||||
);
|
||||
const exchange = await this.jsonFetch("/runtime/session/exchange", { challengeId: challenge.challengeId, signature }, signal, true);
|
||||
if (!exchange.response.ok) throw responseError(exchange.response, exchange.body);
|
||||
const session = parseSession(exchange.body, key.keyId);
|
||||
this.pending = undefined;
|
||||
this.session = session;
|
||||
if (!key.registered) {
|
||||
key.registered = true;
|
||||
await this.keyStore.save(key);
|
||||
}
|
||||
return { state: "connected", session: this.currentSession()!, runtimeVersion: info.apiVersion };
|
||||
}
|
||||
|
||||
private async readInfo(signal?: AbortSignal) {
|
||||
const response = await this.fetchImpl(`${LOCAL_RUNTIME_ENDPOINT}/runtime/info`, {
|
||||
method: "GET",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
signal,
|
||||
});
|
||||
const body = await safeJson(response);
|
||||
if (!response.ok) throw responseError(response, body);
|
||||
return parseInfo(body);
|
||||
}
|
||||
|
||||
private async createChallenge(key: RuntimeBrowserKeyRecord, signal?: AbortSignal) {
|
||||
const payload = key.registered ? { keyId: key.keyId } : { publicKeyJwk: key.publicKeyJwk };
|
||||
const result = await this.jsonFetch("/runtime/session/challenge", payload, signal, true);
|
||||
if (result.response.status === 404 && key.registered) {
|
||||
key.registered = false;
|
||||
await this.keyStore.save(key);
|
||||
const retry = await this.jsonFetch("/runtime/session/challenge", { publicKeyJwk: key.publicKeyJwk }, signal, true);
|
||||
if (!retry.response.ok) throw responseError(retry.response, retry.body);
|
||||
return parseChallenge(retry.body, key.keyId);
|
||||
}
|
||||
if (!result.response.ok) throw responseError(result.response, result.body);
|
||||
return parseChallenge(result.body, key.keyId);
|
||||
}
|
||||
|
||||
private async jsonFetch(path: string, body: unknown, signal?: AbortSignal, allowError = false) {
|
||||
const response = await this.fetchImpl(`${LOCAL_RUNTIME_ENDPOINT}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
signal,
|
||||
});
|
||||
const parsed = await safeJson(response);
|
||||
if (!allowError && !response.ok) throw responseError(response, parsed);
|
||||
return { response, body: parsed };
|
||||
}
|
||||
|
||||
private async requireKey() {
|
||||
const existing = await this.keyStore.load();
|
||||
if (existing) {
|
||||
validateKeyRecord(existing);
|
||||
return existing;
|
||||
}
|
||||
const pair = await this.cryptoImpl.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, false, ["sign", "verify"]);
|
||||
const publicKeyJwk = await this.cryptoImpl.subtle.exportKey("jwk", pair.publicKey);
|
||||
validatePublicJwk(publicKeyJwk);
|
||||
const keyId = await browserKeyId(this.cryptoImpl, publicKeyJwk);
|
||||
const record: RuntimeBrowserKeyRecord = {
|
||||
id: KEY_RECORD_ID,
|
||||
privateKey: pair.privateKey,
|
||||
publicKeyJwk,
|
||||
keyId,
|
||||
registered: false,
|
||||
};
|
||||
await this.keyStore.save(record);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
|
||||
export function createIndexedDbRuntimeKeyStore(): RuntimeBrowserKeyStore {
|
||||
const database = openDB(KEY_DATABASE, 1, {
|
||||
upgrade(db) {
|
||||
if (!db.objectStoreNames.contains(KEY_STORE)) db.createObjectStore(KEY_STORE);
|
||||
},
|
||||
});
|
||||
return {
|
||||
async load() {
|
||||
return (await (await database).get(KEY_STORE, KEY_RECORD_ID)) as RuntimeBrowserKeyRecord | undefined;
|
||||
},
|
||||
async save(record) {
|
||||
await (await database).put(KEY_STORE, record, KEY_RECORD_ID);
|
||||
},
|
||||
async clear() {
|
||||
await (await database).delete(KEY_STORE, KEY_RECORD_ID);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function browserKeyId(cryptoImpl: Crypto, jwk: JsonWebKey) {
|
||||
return await sha256Base64Url(
|
||||
cryptoImpl,
|
||||
new TextEncoder().encode(
|
||||
canonicalize({
|
||||
crv: jwk.crv,
|
||||
kty: jwk.kty,
|
||||
x: jwk.x,
|
||||
y: jwk.y,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function signP1363(cryptoImpl: Crypto, privateKey: CryptoKey, payload: string) {
|
||||
const signature = new Uint8Array(await cryptoImpl.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, privateKey, new TextEncoder().encode(payload)));
|
||||
if (signature.byteLength !== 64) {
|
||||
throw new LocalRuntimeClientError("signature_invalid", "浏览器签名格式不受支持");
|
||||
}
|
||||
return base64Url(signature);
|
||||
}
|
||||
|
||||
async function sha256Base64Url(cryptoImpl: Crypto, value: Uint8Array) {
|
||||
const bytes = new Uint8Array(value.byteLength);
|
||||
bytes.set(value);
|
||||
return base64Url(new Uint8Array(await cryptoImpl.subtle.digest("SHA-256", bytes.buffer)));
|
||||
}
|
||||
|
||||
function randomBase64Url(cryptoImpl: Crypto, bytes: number) {
|
||||
return base64Url(cryptoImpl.getRandomValues(new Uint8Array(bytes)));
|
||||
}
|
||||
|
||||
function base64Url(value: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of value) binary += String.fromCharCode(byte);
|
||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||
}
|
||||
|
||||
function requestBodyBytes(body: BodyInit | null | undefined) {
|
||||
if (body === undefined || body === null) return new Uint8Array();
|
||||
if (typeof body === "string") return new TextEncoder().encode(body);
|
||||
if (body instanceof Uint8Array) return body;
|
||||
throw new LocalRuntimeClientError("request_body_invalid", "本机请求体格式无效");
|
||||
}
|
||||
|
||||
function exactRuntimeUrl(pathAndQuery: string) {
|
||||
if (!pathAndQuery.startsWith("/") || pathAndQuery.includes("#")) {
|
||||
throw new LocalRuntimeClientError("request_target_invalid", "本机请求路径无效");
|
||||
}
|
||||
const url = new URL(pathAndQuery, LOCAL_RUNTIME_ENDPOINT);
|
||||
if (url.origin !== LOCAL_RUNTIME_ENDPOINT || `${url.pathname}${url.search}` !== pathAndQuery) {
|
||||
throw new LocalRuntimeClientError("request_target_invalid", "本机请求路径无效");
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function exactOrigin(value: string) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!["http:", "https:"].includes(url.protocol) || url.pathname !== "/" || url.search || url.hash || url.origin !== value) throw new Error();
|
||||
return url.origin;
|
||||
} catch {
|
||||
throw new LocalRuntimeClientError("origin_invalid", "当前页面来源无效");
|
||||
}
|
||||
}
|
||||
|
||||
function validatePublicJwk(jwk: JsonWebKey) {
|
||||
if (
|
||||
jwk.kty !== "EC" ||
|
||||
jwk.crv !== "P-256" ||
|
||||
!/^[A-Za-z0-9_-]{43}$/.test(jwk.x ?? "") ||
|
||||
!/^[A-Za-z0-9_-]{43}$/.test(jwk.y ?? "") ||
|
||||
jwk.d !== undefined ||
|
||||
jwk.ext !== true ||
|
||||
!Array.isArray(jwk.key_ops) ||
|
||||
jwk.key_ops.length !== 1 ||
|
||||
jwk.key_ops[0] !== "verify"
|
||||
) {
|
||||
throw new LocalRuntimeClientError("browser_key_invalid", "浏览器公钥无效");
|
||||
}
|
||||
}
|
||||
|
||||
function validateKeyRecord(record: RuntimeBrowserKeyRecord) {
|
||||
validatePublicJwk(record.publicKeyJwk);
|
||||
if (
|
||||
record.id !== KEY_RECORD_ID ||
|
||||
record.privateKey.type !== "private" ||
|
||||
record.privateKey.extractable ||
|
||||
record.privateKey.algorithm.name !== "ECDSA" ||
|
||||
record.privateKey.usages.length !== 1 ||
|
||||
record.privateKey.usages[0] !== "sign" ||
|
||||
!/^[A-Za-z0-9_-]{43}$/.test(record.keyId)
|
||||
) {
|
||||
throw new LocalRuntimeClientError("browser_key_invalid", "浏览器密钥无效");
|
||||
}
|
||||
}
|
||||
|
||||
function parseInfo(value: Record<string, unknown>): RuntimeInfo {
|
||||
assertExactKeys(value, ["runtime", "apiVersion", "protocolVersion", "runtimeInstanceId", "originTrusted"]);
|
||||
if (
|
||||
value.runtime !== "framefield-local-runtime" ||
|
||||
value.apiVersion !== 2 ||
|
||||
value.protocolVersion !== "framefield-runtime-session-v1" ||
|
||||
typeof value.runtimeInstanceId !== "string" ||
|
||||
!/^[A-Za-z0-9_-]{8,160}$/.test(value.runtimeInstanceId) ||
|
||||
typeof value.originTrusted !== "boolean"
|
||||
) {
|
||||
throw new LocalRuntimeClientError("runtime_incompatible", "本机运行时版本不兼容");
|
||||
}
|
||||
return value as RuntimeInfo;
|
||||
}
|
||||
|
||||
function parseChallenge(value: Record<string, unknown>, keyId: string): RuntimeChallenge {
|
||||
assertExactKeys(value, ["state", "challengeId", "nonce", "runtimeInstanceId", "expiresAt", "keyId"]);
|
||||
if (value.state !== "challenge" || typeof value.challengeId !== "string" || typeof value.nonce !== "string" || typeof value.runtimeInstanceId !== "string" || typeof value.expiresAt !== "string" || value.keyId !== keyId) {
|
||||
throw new LocalRuntimeClientError("challenge_invalid", "本机会话挑战无效");
|
||||
}
|
||||
return value as RuntimeChallenge;
|
||||
}
|
||||
|
||||
function parseSession(value: Record<string, unknown>, keyId: string): RuntimePublicSession {
|
||||
assertExactKeys(value, ["sessionId", "keyId", "scopes", "expiresAt"]);
|
||||
if (typeof value.sessionId !== "string" || value.sessionId.length < 16 || value.keyId !== keyId || !Array.isArray(value.scopes) || value.scopes.some((scope) => typeof scope !== "string") || typeof value.expiresAt !== "string") {
|
||||
throw new LocalRuntimeClientError("session_invalid", "本机会话响应无效");
|
||||
}
|
||||
return value as RuntimePublicSession;
|
||||
}
|
||||
|
||||
function assertExactKeys(value: Record<string, unknown>, keys: readonly string[]) {
|
||||
const allowed = new Set(keys);
|
||||
if (Object.keys(value).some((key) => !allowed.has(key))) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机运行时响应无效");
|
||||
}
|
||||
}
|
||||
|
||||
async function safeJson(response: Response) {
|
||||
try {
|
||||
const value = (await response.json()) as unknown;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error();
|
||||
return value as Record<string, unknown>;
|
||||
} catch {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机运行时响应无效", response.status);
|
||||
}
|
||||
}
|
||||
|
||||
function responseError(response: Response, body: Record<string, unknown>) {
|
||||
const code = typeof body.code === "string" && body.code in PUBLIC_RUNTIME_ERROR_MESSAGES ? body.code : "runtime_request_failed";
|
||||
return new LocalRuntimeClientError(code, PUBLIC_RUNTIME_ERROR_MESSAGES[code], response.status);
|
||||
}
|
||||
|
||||
const PUBLIC_RUNTIME_ERROR_MESSAGES: Record<string, string> = {
|
||||
challenge_invalid: "本机会话挑战无效",
|
||||
invalid_public_key: "浏览器公钥无效",
|
||||
origin_not_trusted: "当前页面来源未获本机授权",
|
||||
rate_limited: "本机连接请求过多,请稍后重试",
|
||||
registration_not_found: "浏览器密钥尚未注册",
|
||||
runtime_internal_error: "本机运行时请求失败",
|
||||
runtime_request_failed: "本机运行时请求失败",
|
||||
};
|
||||
153
web/src/services/local-runtime.ts
Normal file
153
web/src/services/local-runtime.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
|
||||
|
||||
export type LocalRuntimeModuleId = "canvas-agent" | "dreamina" | "portrait-clearance" | "depth-estimation" | "lineart-estimation" | "pose-estimation";
|
||||
export type LocalRuntimeScope = "runtime:status" | "runtime:revoke" | "canvas:connect" | "dreamina:status" | "dreamina:login" | "dreamina:logout" | "dreamina:run" | "dreamina:models" | "dreamina:generate" | "portrait:status" | "portrait:model" | "portrait:run" | "portrait:read" | "depth:status" | "depth:run" | "lineart:status" | "lineart:run" | "pose:status" | "pose:run";
|
||||
|
||||
export type LocalRuntimeModuleDescriptor = {
|
||||
id: LocalRuntimeModuleId;
|
||||
displayName: string;
|
||||
apiVersion: 1;
|
||||
scopes: LocalRuntimeScope[];
|
||||
};
|
||||
|
||||
export type LocalRuntimeStatus = {
|
||||
runtime: {
|
||||
id: "framefield-local-runtime";
|
||||
version: string;
|
||||
apiVersion: 2;
|
||||
};
|
||||
modules: LocalRuntimeModuleDescriptor[];
|
||||
};
|
||||
|
||||
export type LocalRuntimeTransport = {
|
||||
request(pathAndQuery: string, init?: RequestInit): Promise<Response>;
|
||||
};
|
||||
|
||||
const MAX_RESPONSE_BYTES = 64 * 1024;
|
||||
const MODULE_SCOPES: Record<LocalRuntimeModuleId, ReadonlySet<LocalRuntimeScope>> = {
|
||||
"canvas-agent": new Set(["canvas:connect"]),
|
||||
dreamina: new Set(["dreamina:status", "dreamina:login", "dreamina:logout", "dreamina:run", "dreamina:models", "dreamina:generate"]),
|
||||
"portrait-clearance": new Set(["portrait:status", "portrait:model", "portrait:run", "portrait:read"]),
|
||||
"depth-estimation": new Set(["depth:status", "depth:run"]),
|
||||
"lineart-estimation": new Set(["lineart:status", "lineart:run"]),
|
||||
"pose-estimation": new Set(["pose:status", "pose:run"]),
|
||||
};
|
||||
|
||||
export async function readLocalRuntimeStatus(client: LocalRuntimeTransport, signal?: AbortSignal): Promise<LocalRuntimeStatus> {
|
||||
const response = await client.request("/runtime/status", { method: "GET", signal });
|
||||
if (response.redirected || response.type === "opaqueredirect" || (response.status >= 300 && response.status < 400)) {
|
||||
throw invalidResponse(response.status);
|
||||
}
|
||||
const body = await readBoundedJson(response);
|
||||
if (!response.ok) {
|
||||
const code = response.status === 401 ? "session_required" : response.status === 403 ? "scope_denied" : "runtime_request_failed";
|
||||
throw new LocalRuntimeClientError(code, publicRequestError(code), response.status);
|
||||
}
|
||||
return parseRuntimeStatus(body, response.status);
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_RESPONSE_BYTES) {
|
||||
throw invalidResponse(response.status);
|
||||
}
|
||||
if (!response.body) throw invalidResponse(response.status);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const item = await reader.read();
|
||||
if (item.done) break;
|
||||
total += item.value.byteLength;
|
||||
if (total > MAX_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw invalidResponse(response.status);
|
||||
}
|
||||
chunks.push(item.value);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
||||
} catch {
|
||||
throw invalidResponse(response.status);
|
||||
}
|
||||
}
|
||||
|
||||
function parseRuntimeStatus(value: unknown, status: number): LocalRuntimeStatus {
|
||||
if (!isRecord(value) || !hasExactKeys(value, ["modules", "ok", "runtime"]) || value.ok !== true || !isRecord(value.runtime) || !Array.isArray(value.modules)) {
|
||||
throw invalidResponse(status);
|
||||
}
|
||||
const runtime = value.runtime;
|
||||
if (!hasExactKeys(runtime, ["apiVersion", "id", "version"]) || runtime.id !== "framefield-local-runtime" || runtime.apiVersion !== 2 || typeof runtime.version !== "string" || !/^[A-Za-z0-9._+:-]{1,80}$/.test(runtime.version)) {
|
||||
throw invalidResponse(status);
|
||||
}
|
||||
|
||||
const ids = new Set<LocalRuntimeModuleId>();
|
||||
const modules = value.modules.map((item) => {
|
||||
if (
|
||||
!isRecord(item) ||
|
||||
!hasExactKeys(item, ["apiVersion", "displayName", "id", "scopes"]) ||
|
||||
!isModuleId(item.id) ||
|
||||
ids.has(item.id) ||
|
||||
item.apiVersion !== 1 ||
|
||||
typeof item.displayName !== "string" ||
|
||||
item.displayName.trim() !== item.displayName ||
|
||||
item.displayName.length < 1 ||
|
||||
item.displayName.length > 80 ||
|
||||
!Array.isArray(item.scopes)
|
||||
) {
|
||||
throw invalidResponse(status);
|
||||
}
|
||||
const scopes = item.scopes as unknown[];
|
||||
const allowedScopes = MODULE_SCOPES[item.id];
|
||||
if (scopes.some((scope) => typeof scope !== "string" || !allowedScopes.has(scope as LocalRuntimeScope)) || new Set(scopes).size !== scopes.length) {
|
||||
throw invalidResponse(status);
|
||||
}
|
||||
ids.add(item.id);
|
||||
return {
|
||||
id: item.id,
|
||||
displayName: item.displayName,
|
||||
apiVersion: 1 as const,
|
||||
scopes: [...scopes] as LocalRuntimeScope[],
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
runtime: { id: "framefield-local-runtime", version: runtime.version, apiVersion: 2 },
|
||||
modules,
|
||||
};
|
||||
}
|
||||
|
||||
function hasExactKeys(value: Record<string, unknown>, expected: string[]) {
|
||||
const keys = Object.keys(value).sort();
|
||||
return keys.length === expected.length && keys.every((key, index) => key === expected[index]);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isModuleId(value: unknown): value is LocalRuntimeModuleId {
|
||||
return value === "canvas-agent" || value === "dreamina" || value === "portrait-clearance" || value === "depth-estimation" || value === "lineart-estimation" || value === "pose-estimation";
|
||||
}
|
||||
|
||||
function invalidResponse(status: number) {
|
||||
return new LocalRuntimeClientError("runtime_response_invalid", "本机运行时响应无效", status);
|
||||
}
|
||||
|
||||
function publicRequestError(code: string) {
|
||||
if (code === "session_required") return "本机会话已失效,请重新连接";
|
||||
if (code === "scope_denied") return "本机会话权限不足";
|
||||
return "本机运行时请求失败";
|
||||
}
|
||||
213
web/src/services/pose-runtime.ts
Normal file
213
web/src/services/pose-runtime.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
import { LocalRuntimeClientError } from "@/services/local-runtime-session";
|
||||
import type { LocalRuntimeTransport } from "@/services/local-runtime";
|
||||
import { getLocalRuntimeSessionClient, useLocalRuntimeStore } from "@/stores/use-local-runtime-store";
|
||||
|
||||
const MAX_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const MAX_RESPONSE_BYTES = 32 * 1024 * 1024;
|
||||
const DATA_URL_PATTERN = /^data:(image\/(?:png|jpeg|webp));base64,([A-Za-z0-9+/=_-]+)$/;
|
||||
|
||||
export type LocalPoseResult = {
|
||||
blob: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
modelId: string;
|
||||
device: "cpu";
|
||||
backend: "openpose-body";
|
||||
personCount: number;
|
||||
people?: unknown[];
|
||||
};
|
||||
|
||||
export type LocalPoseRuntimeStatus = {
|
||||
ok: true;
|
||||
module: "pose-estimation";
|
||||
apiVersion: 1;
|
||||
ready: boolean;
|
||||
configured: boolean;
|
||||
modelCached: boolean;
|
||||
loaded: boolean;
|
||||
device: "cpu";
|
||||
modelId: string;
|
||||
backend: "openpose-body";
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function runLocalPoseEstimation(sourceUrl: string, signal?: AbortSignal): Promise<LocalPoseResult> {
|
||||
const dataUrl = await sourceToDataUrl(sourceUrl, signal);
|
||||
const value = await requestJson<unknown>("/pose-estimation/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ schemaVersion: 1, operation: "pose", dataUrl }),
|
||||
headers: { "content-type": "application/json" },
|
||||
signal,
|
||||
});
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "pose-estimation" || value.apiVersion !== 1 || !isRecord(value.result)) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态结果响应无效");
|
||||
}
|
||||
return parseResult(value.result);
|
||||
}
|
||||
|
||||
export async function readLocalPoseRuntimeStatus(signal?: AbortSignal) {
|
||||
return parseStatus(await requestJson<unknown>("/pose-estimation/status", { method: "GET", signal }));
|
||||
}
|
||||
|
||||
async function sourceToDataUrl(sourceUrl: string, signal?: AbortSignal) {
|
||||
if (sourceUrl.startsWith("data:")) {
|
||||
const match = DATA_URL_PATTERN.exec(sourceUrl);
|
||||
if (!match) throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
const bytes = bytesFromBase64(match[2]!);
|
||||
assertImageSize(bytes);
|
||||
return `data:${match[1]};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(sourceUrl, { credentials: "include", redirect: "error", cache: "no-store", signal });
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换输入图片无法读取");
|
||||
}
|
||||
if (!response.ok) throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换输入图片无法读取", response.status);
|
||||
const mimeType = imageMime(response.headers.get("content-type") || response.type);
|
||||
const declaredLength = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > MAX_IMAGE_BYTES) throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换图片不能超过 12MB", 400);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
assertImageSize(bytes);
|
||||
return `data:${mimeType};base64,${bytesToBase64(bytes)}`;
|
||||
}
|
||||
|
||||
async function requestJson<T = unknown>(path: string, init: RequestInit = {}) {
|
||||
const { response, body: responseBody } = await requestRuntimeResponse(path, init);
|
||||
const body = responseBody === undefined ? await parseJsonResponse(response) : responseBody;
|
||||
if (!response.ok) {
|
||||
const code = isRecord(body) && typeof body.code === "string" ? body.code : "pose_runtime_unavailable";
|
||||
const message = isRecord(body) && typeof body.message === "string" ? body.message : "本机姿态运行时不可用";
|
||||
throw new LocalRuntimeClientError(code, message, response.status);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
async function requestRuntimeResponse(path: string, init: RequestInit = {}) {
|
||||
let refreshed = false;
|
||||
while (true) {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await (await ensureTransport()).request(path, init);
|
||||
} catch (error) {
|
||||
if (refreshed || !isSessionRefreshError(error)) throw error;
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (!refreshed && response.status === 401) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const body = await parseJsonResponse(response);
|
||||
if (!refreshed && shouldRefreshSession(response, body)) {
|
||||
await reconnectTransport();
|
||||
refreshed = true;
|
||||
continue;
|
||||
}
|
||||
return { response, body };
|
||||
}
|
||||
return { response };
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureTransport(): Promise<LocalRuntimeTransport> {
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") await state.connect();
|
||||
const current = useLocalRuntimeStore.getState();
|
||||
if (current.connection !== "connected") throw new LocalRuntimeClientError("pose_runtime_unavailable", current.error || "本机姿态运行时不可用");
|
||||
return getLocalRuntimeSessionClient();
|
||||
}
|
||||
|
||||
async function reconnectTransport() {
|
||||
const client = getLocalRuntimeSessionClient();
|
||||
client.revokeLocalSession();
|
||||
await useLocalRuntimeStore.getState().connect();
|
||||
const state = useLocalRuntimeStore.getState();
|
||||
if (state.connection !== "connected") throw new LocalRuntimeClientError("pose_runtime_unavailable", state.error || "本机姿态运行时不可用");
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response: Response) {
|
||||
const text = await boundedText(response, MAX_RESPONSE_BYTES);
|
||||
try { return text ? JSON.parse(text) as unknown : {}; } catch { throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态响应无效", response.status); }
|
||||
}
|
||||
|
||||
async function boundedText(response: Response, limit: number) {
|
||||
const declared = Number(response.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态响应过大", response.status);
|
||||
const text = await response.text();
|
||||
if (text.length > limit) throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态响应过大", response.status);
|
||||
return text;
|
||||
}
|
||||
|
||||
function parseResult(value: Record<string, unknown>): LocalPoseResult {
|
||||
if (value.mimeType !== "image/png" || !isPositiveInteger(value.width) || !isPositiveInteger(value.height) || typeof value.modelId !== "string" || value.device !== "cpu" || value.backend !== "openpose-body" || !isNonNegativeInteger(value.personCount) || typeof value.pngBase64 !== "string") {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态结果无效");
|
||||
}
|
||||
const bytes = bytesFromBase64(value.pngBase64, "runtime_response_invalid", "本机姿态结果无效");
|
||||
if (!bytes.length || bytes.length > MAX_RESPONSE_BYTES) throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态结果过大");
|
||||
return {
|
||||
blob: new Blob([bytes], { type: "image/png" }),
|
||||
width: value.width,
|
||||
height: value.height,
|
||||
modelId: value.modelId,
|
||||
device: "cpu",
|
||||
backend: "openpose-body",
|
||||
personCount: value.personCount,
|
||||
people: Array.isArray(value.people) ? value.people : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStatus(value: unknown): LocalPoseRuntimeStatus {
|
||||
if (!isRecord(value) || value.ok !== true || value.module !== "pose-estimation" || value.apiVersion !== 1 || typeof value.ready !== "boolean" || typeof value.configured !== "boolean" || typeof value.modelCached !== "boolean" || typeof value.loaded !== "boolean" || value.device !== "cpu" || typeof value.modelId !== "string" || value.backend !== "openpose-body" || (value.reason !== undefined && typeof value.reason !== "string")) {
|
||||
throw new LocalRuntimeClientError("runtime_response_invalid", "本机姿态状态无效");
|
||||
}
|
||||
return value as LocalPoseRuntimeStatus;
|
||||
}
|
||||
|
||||
function imageMime(value: string): "image/jpeg" | "image/png" | "image/webp" {
|
||||
const mime = value.startsWith("image/") ? value.split(";", 1)[0] : "";
|
||||
if (mime === "image/jpeg" || mime === "image/png" || mime === "image/webp") return mime;
|
||||
throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换只支持 PNG、JPEG 或 WebP 图片", 400);
|
||||
}
|
||||
|
||||
function assertImageSize(bytes: Uint8Array) {
|
||||
if (!bytes.byteLength || bytes.byteLength > MAX_IMAGE_BYTES) throw new LocalRuntimeClientError("pose_input_invalid", "姿态转换图片不能超过 12MB", 400);
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array) {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesFromBase64(value: string, code = "pose_input_invalid", message = "姿态转换输入图片无效") {
|
||||
let binary: string;
|
||||
try { binary = atob(value.replaceAll("-", "+").replaceAll("_", "/")); } catch { throw new LocalRuntimeClientError(code, message, code === "pose_input_invalid" ? 400 : 0); }
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function isSessionRefreshError(error: unknown): error is LocalRuntimeClientError {
|
||||
return error instanceof LocalRuntimeClientError && ["session_required", "session_invalid", "scope_denied"].includes(error.code);
|
||||
}
|
||||
|
||||
function shouldRefreshSession(response: Response, body: unknown) {
|
||||
return response.status === 401 || (response.status === 403 && isRecord(body) && body.code === "scope_denied");
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
function isPositiveInteger(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value > 0;
|
||||
}
|
||||
|
||||
function isNonNegativeInteger(value: unknown): value is number {
|
||||
return typeof value === "number" && Number.isInteger(value) && value >= 0;
|
||||
}
|
||||
Reference in New Issue
Block a user