feat(canvas): 画布节点 - 恢复批量创作表与转换能力

This commit is contained in:
ddCat
2026-09-15 20:56:19 +08:00
parent f59b59b3e6
commit c57f1b36ae
36 changed files with 3341 additions and 34 deletions

View 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;
}

View 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;
}

View 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: "本机运行时请求失败",
};

View 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 "本机运行时请求失败";
}

View 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;
}