feat(platform): 开源发布 - 初始化 Open AI Canvas
This commit is contained in:
commit
aea35896eb
365 files changed
+66410
No files matched your search
@@ -0,0 +1,175 @@
|
||||
import localforage from "localforage";
|
||||
|
||||
import { nanoid } from "nanoid";
|
||||
import { readImageMeta } from "@/lib/image-utils";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { importResourceFromUrl, isResourceUrl, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey, resolveResourceUrl, uploadResourceFile } from "@/services/api/resources";
|
||||
import { cacheResourceObjectUrl, getCachedResourceBlob, getCachedResourceObjectUrl, primeResourceBlobCache } from "@/services/resource-blob-cache";
|
||||
|
||||
export type UploadedImage = {
|
||||
url: string;
|
||||
storageKey: string;
|
||||
width: number;
|
||||
height: number;
|
||||
bytes: number;
|
||||
mimeType: string;
|
||||
};
|
||||
|
||||
const store = localforage.createInstance({ name: "infinite-canvas", storeName: "image_files" });
|
||||
const objectUrls = new Map<string, string>();
|
||||
|
||||
export async function uploadImage(input: string | Blob): Promise<UploadedImage> {
|
||||
if (typeof input === "string" && shouldImportRemoteImage(input)) {
|
||||
try {
|
||||
const resource = await importResourceFromUrl(input, "image");
|
||||
return {
|
||||
url: resource.publicUrl || resourceFileUrl(resource.id),
|
||||
storageKey: resourceStorageKey(resource.id),
|
||||
width: resource.width || 1024,
|
||||
height: resource.height || 1024,
|
||||
bytes: resource.size || 0,
|
||||
mimeType: resource.mimeType || "image/png",
|
||||
};
|
||||
} catch {
|
||||
// Keep the browser-side path as a fallback for CORS-enabled HTTPS images.
|
||||
}
|
||||
}
|
||||
const blob = typeof input === "string" ? await (await fetch(input)).blob() : input;
|
||||
const previewUrl = URL.createObjectURL(blob);
|
||||
const meta = await readImageMeta(previewUrl);
|
||||
try {
|
||||
const resource = await uploadResourceFile(blob, "image", { width: meta.width, height: meta.height, fileName: input instanceof File ? input.name : undefined });
|
||||
await primeResourceBlobCache(resourceStorageKey(resource.id), blob).catch(() => "");
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
return {
|
||||
url: resource.publicUrl || resourceFileUrl(resource.id),
|
||||
storageKey: resourceStorageKey(resource.id),
|
||||
width: resource.width || meta.width,
|
||||
height: resource.height || meta.height,
|
||||
bytes: resource.size || blob.size,
|
||||
mimeType: resource.mimeType || blob.type || meta.mimeType,
|
||||
};
|
||||
} catch {
|
||||
// OSS is optional during local/self-hosted setup. Keep the existing local fallback.
|
||||
}
|
||||
const storageKey = `image:${getActiveUserScope()}:${nanoid()}`;
|
||||
await store.setItem(storageKey, blob);
|
||||
const url = previewUrl;
|
||||
objectUrls.set(storageKey, url);
|
||||
return { url, storageKey, width: meta.width, height: meta.height, bytes: blob.size, mimeType: blob.type || meta.mimeType };
|
||||
}
|
||||
|
||||
function shouldImportRemoteImage(input: string) {
|
||||
return /^https?:\/\//i.test(input) && !isResourceUrl(input);
|
||||
}
|
||||
|
||||
export async function resolveImageUrl(storageKey?: string, fallback = "", options?: { cacheMiss?: boolean }) {
|
||||
if (!storageKey) return fallback;
|
||||
if (resourceIdFromStorageKey(storageKey)) {
|
||||
const cached = await getCachedResourceObjectUrl(storageKey).catch(() => "");
|
||||
if (cached) return cached;
|
||||
if (options?.cacheMiss) {
|
||||
const populated = await cacheResourceObjectUrl(storageKey).catch(() => "");
|
||||
if (populated) return populated;
|
||||
}
|
||||
return resolveResourceUrl(storageKey, fallback);
|
||||
}
|
||||
const cached = objectUrls.get(storageKey);
|
||||
if (cached) return cached;
|
||||
const blob = await store.getItem<Blob>(storageKey);
|
||||
if (!blob) return fallback;
|
||||
const url = URL.createObjectURL(blob);
|
||||
objectUrls.set(storageKey, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function getImageBlob(storageKey: string) {
|
||||
if (resourceIdFromStorageKey(storageKey)) return getCachedResourceBlob(storageKey);
|
||||
return store.getItem<Blob>(storageKey);
|
||||
}
|
||||
|
||||
export async function setImageBlob(storageKey: string, blob: Blob) {
|
||||
if (resourceIdFromStorageKey(storageKey)) return primeResourceBlobCache(storageKey, blob);
|
||||
await store.setItem(storageKey, blob);
|
||||
const url = URL.createObjectURL(blob);
|
||||
objectUrls.set(storageKey, url);
|
||||
return url;
|
||||
}
|
||||
|
||||
export async function imageToDataUrl(image: { url?: string; dataUrl?: string; storageKey?: string; name?: string; type?: string; mimeType?: string }) {
|
||||
if (image.storageKey) {
|
||||
const blob = await getImageBlob(image.storageKey);
|
||||
if (blob) return blobToDataUrl(await normalizeImageBlob(blob, image.name || image.url));
|
||||
}
|
||||
const url = image.dataUrl || (await resolveImageUrl(image.storageKey, image.url || ""));
|
||||
if (!url) return url;
|
||||
if (url.startsWith("data:image/")) return url;
|
||||
if (url.startsWith("data:")) return blobToDataUrl(await normalizeImageBlob(await (await fetch(url)).blob(), image.name));
|
||||
const blob = await (await fetch(url, { credentials: isResourceUrl(url) ? "include" : "same-origin" })).blob();
|
||||
return blobToDataUrl(await normalizeImageBlob(blob, image.name || url));
|
||||
}
|
||||
|
||||
export async function deleteStoredImages(keys: Iterable<string>) {
|
||||
await Promise.all(
|
||||
Array.from(new Set(keys)).map(async (key) => {
|
||||
if (resourceIdFromStorageKey(key)) return;
|
||||
const url = objectUrls.get(key);
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
objectUrls.delete(key);
|
||||
await store.removeItem(key);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function cleanupUnusedImages(usedData: unknown) {
|
||||
const usedKeys = collectImageStorageKeys(usedData);
|
||||
const currentPrefix = `image:${getActiveUserScope()}:`;
|
||||
const unused: string[] = [];
|
||||
await store.iterate((_value, key) => {
|
||||
if (key.startsWith(currentPrefix) && !usedKeys.has(key)) unused.push(key);
|
||||
});
|
||||
await deleteStoredImages(unused);
|
||||
}
|
||||
|
||||
export function collectImageStorageKeys(value: unknown, keys = new Set<string>()) {
|
||||
if (!value || typeof value !== "object") return keys;
|
||||
if ("storageKey" in value && typeof value.storageKey === "string" && (value.storageKey.startsWith("image:") || resourceIdFromStorageKey(value.storageKey))) keys.add(value.storageKey);
|
||||
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectImageStorageKeys(child, keys)) : collectImageStorageKeys(item, keys)));
|
||||
return keys;
|
||||
}
|
||||
|
||||
function blobToDataUrl(blob: Blob) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
}
|
||||
|
||||
async function normalizeImageBlob(blob: Blob, sourceName = "") {
|
||||
if (blob.type.startsWith("image/")) return blob;
|
||||
const bytes = new Uint8Array(await blob.slice(0, 32).arrayBuffer());
|
||||
const mimeType = detectImageMimeType(bytes) || imageMimeTypeFromName(sourceName);
|
||||
if (!mimeType) throw new Error("无法识别参考图片格式,请重新上传 PNG、JPEG、WebP 或 GIF 图片");
|
||||
return blob.slice(0, blob.size, mimeType);
|
||||
}
|
||||
|
||||
function detectImageMimeType(bytes: Uint8Array) {
|
||||
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) return "image/png";
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
|
||||
if (bytes.length >= 6 && String.fromCharCode(...bytes.slice(0, 6)).startsWith("GIF8")) return "image/gif";
|
||||
if (bytes.length >= 12 && String.fromCharCode(...bytes.slice(0, 4)) === "RIFF" && String.fromCharCode(...bytes.slice(8, 12)) === "WEBP") return "image/webp";
|
||||
if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) return "image/bmp";
|
||||
return "";
|
||||
}
|
||||
|
||||
function imageMimeTypeFromName(value: string) {
|
||||
const path = value.toLowerCase().split(/[?#]/)[0];
|
||||
if (path.endsWith(".png")) return "image/png";
|
||||
if (path.endsWith(".jpg") || path.endsWith(".jpeg")) return "image/jpeg";
|
||||
if (path.endsWith(".webp")) return "image/webp";
|
||||
if (path.endsWith(".gif")) return "image/gif";
|
||||
if (path.endsWith(".bmp")) return "image/bmp";
|
||||
return "";
|
||||
}
|
||||
Reference in new issue
Block a user