refactor(*): 代码质量 - 优化前后端实现并完善协议与画布验证
This commit is contained in:
@@ -143,7 +143,7 @@ export function getAntThemeConfig(dark: boolean, skinID: unknown = "classic"): T
|
||||
|
||||
return {
|
||||
algorithm: dark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
|
||||
cssVar: { key: `infinite-canvas-${normalizeSkinID(skinID)}-${dark ? "dark" : "light"}` },
|
||||
cssVar: { key: `yingce-${normalizeSkinID(skinID)}-${dark ? "dark" : "light"}` },
|
||||
token: {
|
||||
colorPrimary: color.solidBg,
|
||||
colorPrimaryHover: color.solidHoverBg,
|
||||
|
||||
@@ -29,7 +29,7 @@ export type ResolvedCanvasAppearance = {
|
||||
|
||||
export const DEFAULT_CANVAS_BACKGROUND_MODE: CanvasBackgroundMode = "dots";
|
||||
|
||||
const CANVAS_APPEARANCE_DEFAULT_KEY = "infinite-canvas:canvas-appearance-default";
|
||||
const CANVAS_APPEARANCE_DEFAULT_KEY = "yingce:canvas-appearance-default";
|
||||
const HEX_COLOR_PATTERN = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
||||
const CUSTOM_GRID_COLOR: Record<CanvasColorTheme, string> = { light: "#000000", dark: "#000000" };
|
||||
const CUSTOM_GRID_OPACITY: Record<CanvasColorTheme, number> = { light: 80, dark: 100 };
|
||||
|
||||
@@ -32,9 +32,9 @@ export type CanvasDrawingRender = CanvasDrawingRenderDraft & {
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const drawingStore = localforage.createInstance({ name: "infinite-canvas", storeName: "drawing_documents" });
|
||||
const drawingPreviewStore = localforage.createInstance({ name: "infinite-canvas", storeName: "drawing_previews" });
|
||||
const drawingRenderStore = localforage.createInstance({ name: "infinite-canvas", storeName: "drawing_generation_renders" });
|
||||
const drawingStore = localforage.createInstance({ name: "yingce", storeName: "drawing_documents" });
|
||||
const drawingPreviewStore = localforage.createInstance({ name: "yingce", storeName: "drawing_previews" });
|
||||
const drawingRenderStore = localforage.createInstance({ name: "yingce", storeName: "drawing_generation_renders" });
|
||||
const INITIAL_DRAWING_RENDER_MAX_DIMENSION = 2048;
|
||||
const INITIAL_DRAWING_RENDER_PADDING = 24;
|
||||
|
||||
|
||||
@@ -1,78 +1,46 @@
|
||||
import { saveAs } from "file-saver";
|
||||
|
||||
import { createZip } from "@/lib/zip";
|
||||
import { getMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import type { CanvasExportAsset, CanvasExportFile } from "@/types/canvas-export";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
import { MediaPackage, downloadName, packageSegment, referencedStorageKeys } from "@/lib/media-package";
|
||||
import { loadCanvasDrawing, loadCanvasDrawingPreview, loadCanvasDrawingRender } from "@/lib/canvas/canvas-drawing-storage";
|
||||
import type { CanvasDrawingExport } from "@/types/canvas-export";
|
||||
import type { CanvasDrawingExport, CanvasExportFile, CanvasProjectExportItem } from "@/types/canvas-export";
|
||||
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
|
||||
|
||||
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "画布", options: { includeLocalDrawings?: boolean } = {}) {
|
||||
const zipFiles: { name: string; data: BlobPart }[] = [];
|
||||
const exportedProjects = await Promise.all(
|
||||
projects.map(async (project) => {
|
||||
const files: CanvasExportAsset[] = [];
|
||||
await Promise.all(
|
||||
collectStorageKeys(project).map(async (storageKey) => {
|
||||
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
|
||||
if (!blob) return;
|
||||
const path = `projects/${project.id}/files/${safeFileName(storageKey)}.${fileExtension(blob.type, storageKey)}`;
|
||||
files.push({ storageKey, path, mimeType: blob.type || "application/octet-stream", bytes: blob.size });
|
||||
zipFiles.push({ name: path, data: blob });
|
||||
}),
|
||||
);
|
||||
const drawingDocuments = (await Promise.all(project.nodes.filter((node) => options.includeLocalDrawings !== false && node.type === "drawing" && node.metadata?.drawingId).map(async (node): Promise<CanvasDrawingExport | null> => {
|
||||
const drawingId = node.metadata?.drawingId;
|
||||
if (!drawingId) return null;
|
||||
const [saved, preview, render] = await Promise.all([
|
||||
loadCanvasDrawing(project.id, drawingId),
|
||||
loadCanvasDrawingPreview(project.id, drawingId),
|
||||
loadCanvasDrawingRender(project.id, drawingId),
|
||||
]);
|
||||
if (!saved) return null;
|
||||
const previewPath = preview ? `projects/${project.id}/drawings/${safeFileName(drawingId)}.png` : undefined;
|
||||
if (preview && previewPath) zipFiles.push({ name: previewPath, data: preview });
|
||||
const generationRenderPath = render ? `projects/${project.id}/drawings/${safeFileName(drawingId)}.generation.png` : undefined;
|
||||
if (render && generationRenderPath) zipFiles.push({ name: generationRenderPath, data: render.blob });
|
||||
return {
|
||||
drawingId,
|
||||
...saved,
|
||||
previewPath,
|
||||
generationRender: render && generationRenderPath
|
||||
? { path: generationRenderPath, pageId: render.pageId, width: render.width, height: render.height, mimeType: render.mimeType, background: render.background }
|
||||
: undefined,
|
||||
} satisfies CanvasDrawingExport;
|
||||
}))).filter((item): item is CanvasDrawingExport => item !== null);
|
||||
drawingDocuments.forEach((document) => zipFiles.push({ name: `projects/${project.id}/drawings/${safeFileName(document.drawingId)}.json`, data: JSON.stringify(document) }));
|
||||
return { project, files, drawingDocuments };
|
||||
}),
|
||||
);
|
||||
|
||||
const data: CanvasExportFile = { app: "infinite-canvas", version: 4, exportedAt: new Date().toISOString(), projects: exportedProjects };
|
||||
const zip = await createZip([{ name: "projects.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
|
||||
saveAs(zip, `${safeFileName(fileName)}.zip`);
|
||||
async function addDrawing(archive: MediaPackage, project: CanvasProject, drawingId: string, directory: string): Promise<CanvasDrawingExport | null> {
|
||||
const document = await loadCanvasDrawing(project.id, drawingId);
|
||||
if (!document) return null;
|
||||
const stem = `${directory}/drawings/${packageSegment(drawingId)}`;
|
||||
const [preview, generation] = await Promise.all([loadCanvasDrawingPreview(project.id, drawingId), loadCanvasDrawingRender(project.id, drawingId)]);
|
||||
const saved: CanvasDrawingExport = { ...document, drawingId };
|
||||
if (preview) { saved.previewPath = `${stem}.png`; archive.put(saved.previewPath, preview); }
|
||||
if (generation) {
|
||||
const { pageId, width, height, mimeType, background } = generation;
|
||||
saved.generationRender = { path: `${stem}.generation.png`, pageId, width, height, mimeType, background };
|
||||
archive.put(saved.generationRender.path, generation.blob);
|
||||
}
|
||||
archive.put(`${stem}.json`, JSON.stringify(saved));
|
||||
return saved;
|
||||
}
|
||||
|
||||
function collectStorageKeys(value: unknown, keys = new Set<string>()) {
|
||||
if (!value || typeof value !== "object") return [...keys];
|
||||
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.includes(":")) keys.add(value.storageKey);
|
||||
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectStorageKeys(child, keys)) : collectStorageKeys(item, keys)));
|
||||
return [...keys];
|
||||
}
|
||||
|
||||
function safeFileName(value: string) {
|
||||
return value.replace(/[\\/:*?"<>|]/g, "_");
|
||||
}
|
||||
|
||||
function fileExtension(mimeType: string, storageKey: string) {
|
||||
if (mimeType.includes("png")) return "png";
|
||||
if (mimeType.includes("jpeg")) return "jpg";
|
||||
if (mimeType.includes("webp")) return "webp";
|
||||
if (mimeType.includes("gif")) return "gif";
|
||||
if (mimeType.includes("mp4")) return "mp4";
|
||||
if (mimeType.includes("webm")) return "webm";
|
||||
if (mimeType.includes("gltf-binary")) return "glb";
|
||||
if (mimeType.includes("gltf+json")) return "gltf";
|
||||
return storageKey.startsWith("image:") ? "png" : "bin";
|
||||
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "画布", options: { includeLocalDrawings?: boolean } = {}): Promise<void> {
|
||||
const archive = new MediaPackage();
|
||||
const items: CanvasProjectExportItem[] = [];
|
||||
// Snapshot once so edits during asynchronous blob reads cannot change the manifest midway.
|
||||
const snapshot = structuredClone(projects);
|
||||
const projectIds = new Set<string>();
|
||||
for (const project of snapshot) {
|
||||
if (projectIds.has(project.id)) throw new Error("不能重复导出同一个画布项目");
|
||||
projectIds.add(project.id);
|
||||
const directory = `projects/${packageSegment(project.id)}`;
|
||||
const files = await archive.collect(referencedStorageKeys(project), `${directory}/files`);
|
||||
const drawingDocuments: CanvasDrawingExport[] = [];
|
||||
if (options.includeLocalDrawings !== false) {
|
||||
const ids = new Set(project.nodes.flatMap((node) => node.type === "drawing" && node.metadata?.drawingId ? [node.metadata.drawingId] : []));
|
||||
for (const drawingId of ids) {
|
||||
const document = await addDrawing(archive, project, drawingId, directory);
|
||||
if (document) drawingDocuments.push(document);
|
||||
}
|
||||
}
|
||||
items.push({ project, files, drawingDocuments });
|
||||
}
|
||||
const manifest: CanvasExportFile = { app: "yingce", version: 4, exportedAt: new Date().toISOString(), projects: items };
|
||||
saveAs(await archive.finish("projects.json", manifest), `${downloadName(fileName)}.zip`);
|
||||
}
|
||||
|
||||
@@ -1,182 +1,54 @@
|
||||
export type ImageCropRect = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
import { loadRasterImage } from "@/lib/image-raster";
|
||||
import { planImageTiles, resolveCropPixels, resolveUpscaleSize, type ImageCropRect, type ImageSplitParams, type ImageSplitPiece, type ImageUpscaleAlgorithm, type ImageUpscaleParams, type PixelSize } from "./image-operation-plan";
|
||||
|
||||
export { MAX_UPSCALE_LONG_EDGE, resolveUpscaleSize } from "./image-operation-plan";
|
||||
export type { ImageCropRect, ImageSplitParams, ImageSplitPiece, ImageUpscaleAlgorithm, ImageUpscaleParams } from "./image-operation-plan";
|
||||
|
||||
type RasterCommand = {
|
||||
region: ImageCropRect;
|
||||
output: PixelSize;
|
||||
sampling: ImageUpscaleAlgorithm;
|
||||
};
|
||||
|
||||
export type ImageAngleTransform = {
|
||||
horizontalAngle: number;
|
||||
pitchAngle: number;
|
||||
cameraDistance: number;
|
||||
wideAngle: boolean;
|
||||
};
|
||||
|
||||
export type ImageUpscaleAlgorithm = "nearest" | "bilinear" | "high";
|
||||
|
||||
export const MAX_UPSCALE_LONG_EDGE = 4096;
|
||||
|
||||
export type ImageUpscaleParams = {
|
||||
targetLongEdge: number;
|
||||
algorithm: ImageUpscaleAlgorithm;
|
||||
};
|
||||
|
||||
export type ImageSplitParams = {
|
||||
rows: number;
|
||||
columns: number;
|
||||
};
|
||||
|
||||
export type ImageSplitPiece = {
|
||||
row: number;
|
||||
column: number;
|
||||
dataUrl: string;
|
||||
};
|
||||
|
||||
export async function cropDataUrl(dataUrl: string, crop?: ImageCropRect) {
|
||||
const image = await loadImage(dataUrl);
|
||||
if (crop) {
|
||||
return drawCrop(image, Math.floor(crop.x * image.width), Math.floor(crop.y * image.height), Math.ceil(crop.width * image.width), Math.ceil(crop.height * image.height));
|
||||
}
|
||||
const size = Math.min(image.width, image.height);
|
||||
const sx = Math.max(0, Math.floor((image.width - size) / 2));
|
||||
const sy = Math.max(0, Math.floor((image.height - size) / 2));
|
||||
return drawCrop(image, sx, sy, size, size);
|
||||
/** One raster pass shared by crop, grid extraction and resize. Failures never return the original as success. */
|
||||
function renderPng(source: HTMLImageElement, command: RasterCommand): string {
|
||||
const output = document.createElement("canvas");
|
||||
Object.assign(output, command.output);
|
||||
const painter = output.getContext("2d");
|
||||
if (painter === null) throw new Error("浏览器无法创建图片处理画布");
|
||||
const { region, sampling } = command;
|
||||
painter.imageSmoothingEnabled = sampling !== "nearest";
|
||||
painter.imageSmoothingQuality = sampling === "high" ? "high" : "low";
|
||||
painter.drawImage(source, region.x, region.y, region.width, region.height, 0, 0, output.width, output.height);
|
||||
const result = output.toDataURL("image/png");
|
||||
if (!result.startsWith("data:image/png")) throw new Error("图片导出失败");
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function splitDataUrl(dataUrl: string, params: ImageSplitParams): Promise<ImageSplitPiece[]> {
|
||||
const image = await loadImage(dataUrl);
|
||||
const rows = Math.max(1, Math.floor(params.rows));
|
||||
const columns = Math.max(1, Math.floor(params.columns));
|
||||
const pieces: ImageSplitPiece[] = [];
|
||||
const dimensions = (image: HTMLImageElement): PixelSize => ({ width: image.naturalWidth, height: image.naturalHeight });
|
||||
|
||||
for (let row = 0; row < rows; row += 1) {
|
||||
const sy = Math.floor((row * image.height) / rows);
|
||||
const sh = Math.floor(((row + 1) * image.height) / rows) - sy;
|
||||
for (let column = 0; column < columns; column += 1) {
|
||||
const sx = Math.floor((column * image.width) / columns);
|
||||
const sw = Math.floor(((column + 1) * image.width) / columns) - sx;
|
||||
pieces.push({ row, column, dataUrl: drawCrop(image, sx, sy, sw, sh) });
|
||||
}
|
||||
}
|
||||
|
||||
return pieces;
|
||||
export async function cropDataUrl(source: string, selection?: ImageCropRect): Promise<string> {
|
||||
const picture = await loadRasterImage(source);
|
||||
const region = resolveCropPixels(dimensions(picture), selection);
|
||||
return renderPng(picture, { region, output: { width: region.width, height: region.height }, sampling: "nearest" });
|
||||
}
|
||||
|
||||
export async function transformAngleDataUrl(dataUrl: string, params: ImageAngleTransform) {
|
||||
const image = await loadImage(dataUrl);
|
||||
const canvas = document.createElement("canvas");
|
||||
const padding = Math.round(Math.max(image.width, image.height) * 0.18);
|
||||
canvas.width = image.width + padding * 2;
|
||||
canvas.height = image.height + padding * 2;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return dataUrl;
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const horizontal = params.horizontalAngle / 60;
|
||||
const pitch = params.pitchAngle / 45;
|
||||
const distanceScale = 1.12 - params.cameraDistance * 0.035;
|
||||
const wideScale = params.wideAngle ? 0.88 : 1;
|
||||
const scale = Math.max(0.64, Math.min(1.1, distanceScale * wideScale));
|
||||
const width = image.width * scale * (1 - Math.abs(horizontal) * 0.28);
|
||||
const height = image.height * scale * (1 - Math.abs(pitch) * 0.18);
|
||||
const cx = canvas.width / 2;
|
||||
const cy = canvas.height / 2;
|
||||
const skewX = horizontal * image.width * 0.18;
|
||||
const skewY = pitch * image.height * 0.12;
|
||||
const x = cx - width / 2 + horizontal * padding * 0.5;
|
||||
const y = cy - height / 2 + pitch * padding * 0.45;
|
||||
|
||||
context.save();
|
||||
context.setTransform(1, pitch * 0.08, horizontal * -0.1, 1, 0, 0);
|
||||
context.drawImage(image, x + skewX, y + skewY, width, height);
|
||||
context.restore();
|
||||
|
||||
if (params.wideAngle) {
|
||||
const gradient = context.createRadialGradient(cx, cy, Math.min(canvas.width, canvas.height) * 0.2, cx, cy, Math.max(canvas.width, canvas.height) * 0.62);
|
||||
gradient.addColorStop(0, "rgba(255,255,255,0)");
|
||||
gradient.addColorStop(1, "rgba(0,0,0,0.18)");
|
||||
context.fillStyle = gradient;
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
return canvas.toDataURL("image/png");
|
||||
export async function splitDataUrl(source: string, grid: ImageSplitParams): Promise<ImageSplitPiece[]> {
|
||||
const picture = await loadRasterImage(source);
|
||||
return planImageTiles(dimensions(picture), grid).map((region) => ({
|
||||
row: region.row,
|
||||
column: region.column,
|
||||
dataUrl: renderPng(picture, { region, output: { width: region.width, height: region.height }, sampling: "nearest" }),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function upscaleDataUrl(dataUrl: string, params: ImageUpscaleParams) {
|
||||
const image = await loadImage(dataUrl);
|
||||
const { width, height } = resolveUpscaleSize(image.width, image.height, params.targetLongEdge);
|
||||
return params.algorithm === "high" ? drawStepUpscale(image, width, height) : drawResize(image, image.width, image.height, width, height, params.algorithm);
|
||||
}
|
||||
|
||||
export function resolveUpscaleSize(width: number, height: number, targetLongEdge: number) {
|
||||
const longEdge = Math.max(1, width, height);
|
||||
const target = Math.min(MAX_UPSCALE_LONG_EDGE, Math.max(1, Math.round(targetLongEdge)));
|
||||
const scale = target / longEdge;
|
||||
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) };
|
||||
}
|
||||
|
||||
function drawCrop(image: HTMLImageElement, sx: number, sy: number, sw: number, sh: number) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.max(1, sw);
|
||||
canvas.height = Math.max(1, sh);
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return image.src;
|
||||
context.drawImage(image, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
||||
return canvas.toDataURL("image/png");
|
||||
}
|
||||
|
||||
function drawStepUpscale(image: HTMLImageElement, width: number, height: number) {
|
||||
let source: CanvasImageSource = image;
|
||||
let sourceWidth = image.width;
|
||||
let sourceHeight = image.height;
|
||||
|
||||
while (sourceWidth * 2 < width && sourceHeight * 2 < height) {
|
||||
const nextWidth = sourceWidth * 2;
|
||||
const nextHeight = sourceHeight * 2;
|
||||
const next = drawResizeCanvas(source, sourceWidth, sourceHeight, nextWidth, nextHeight, "high");
|
||||
source = next;
|
||||
sourceWidth = nextWidth;
|
||||
sourceHeight = nextHeight;
|
||||
}
|
||||
|
||||
return drawResize(source, sourceWidth, sourceHeight, width, height, "high");
|
||||
}
|
||||
|
||||
function drawResize(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
|
||||
return drawResizeCanvas(source, sourceWidth, sourceHeight, width, height, algorithm).toDataURL("image/png");
|
||||
}
|
||||
|
||||
function drawResizeCanvas(source: CanvasImageSource, sourceWidth: number, sourceHeight: number, width: number, height: number, algorithm: ImageUpscaleAlgorithm) {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return canvas;
|
||||
context.imageSmoothingEnabled = algorithm !== "nearest";
|
||||
context.imageSmoothingQuality = algorithm === "bilinear" ? "medium" : "high";
|
||||
context.drawImage(source, 0, 0, sourceWidth, sourceHeight, 0, 0, width, height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function loadImage(dataUrl: string) {
|
||||
return new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
// 只有跨源 HTTP(S) 地址才需要声明匿名跨域;同源的 /api 资源不能
|
||||
// 强制走 CORS,否则缺少 ACAO 响应头时反而会加载失败。
|
||||
if (isCrossOriginHttpUrl(dataUrl)) image.crossOrigin = "anonymous";
|
||||
image.onload = () => resolve(image);
|
||||
// 缺少 onerror 时加载失败会让 Promise 永久挂起,调用方 await 后表现为「点击无反应」。
|
||||
image.onerror = () => reject(new Error("图片加载失败,无法处理该图片"));
|
||||
image.src = dataUrl;
|
||||
export async function upscaleDataUrl(source: string, options: ImageUpscaleParams): Promise<string> {
|
||||
if (!["nearest", "bilinear", "high"].includes(options.algorithm)) throw new RangeError("不支持的图片插值方式");
|
||||
const picture = await loadRasterImage(source);
|
||||
const size = dimensions(picture);
|
||||
return renderPng(picture, {
|
||||
region: { ...size, x: 0, y: 0 },
|
||||
output: resolveUpscaleSize(size.width, size.height, options.targetLongEdge),
|
||||
sampling: options.algorithm,
|
||||
});
|
||||
}
|
||||
|
||||
function isCrossOriginHttpUrl(value: string) {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const url = new URL(value, window.location.href);
|
||||
return (url.protocol === "http:" || url.protocol === "https:") && url.origin !== window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
44
web/src/lib/canvas/image-crop-selection.ts
Normal file
44
web/src/lib/canvas/image-crop-selection.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ImageCropRect } from "./image-operation-plan";
|
||||
export type CropGrip = readonly [-1 | 0 | 1, -1 | 0 | 1];
|
||||
const limit = (n: number, a: number, b: number) => Math.max(a, Math.min(b, n));
|
||||
const minimum = 0.06;
|
||||
|
||||
export function translateSelection(box: ImageCropRect, dx: number, dy: number): ImageCropRect {
|
||||
return { width: box.width, height: box.height, x: limit(box.x + dx, 0, 1 - box.width), y: limit(box.y + dy, 0, 1 - box.height) };
|
||||
}
|
||||
|
||||
export function resizeSelection(box: ImageCropRect, grip: CropGrip, dx: number, dy: number, fixedAspect: boolean): ImageCropRect {
|
||||
const [horizontal, vertical] = grip;
|
||||
if (!horizontal && !vertical) return box;
|
||||
if (!fixedAspect) {
|
||||
const minWidth = Math.min(minimum, box.width);
|
||||
const minHeight = Math.min(minimum, box.height);
|
||||
const left = horizontal < 0 ? limit(box.x + dx, 0, box.x + box.width - minWidth) : box.x;
|
||||
const right = horizontal > 0 ? limit(box.x + box.width + dx, box.x + minWidth, 1) : box.x + box.width;
|
||||
const top = vertical < 0 ? limit(box.y + dy, 0, box.y + box.height - minHeight) : box.y;
|
||||
const bottom = vertical > 0 ? limit(box.y + box.height + dy, box.y + minHeight, 1) : box.y + box.height;
|
||||
return { x: left, y: top, width: right - left, height: bottom - top };
|
||||
}
|
||||
// Project the gesture onto the original rectangle's scale vector around its opposite anchor.
|
||||
const fx = (1 - horizontal) / 2;
|
||||
const fy = (1 - vertical) / 2;
|
||||
const ax = box.x + box.width * fx;
|
||||
const ay = box.y + box.height * fy;
|
||||
const vx = horizontal * box.width;
|
||||
const vy = vertical * box.height;
|
||||
const proposed = 1 + (dx * vx + dy * vy) / (vx * vx + vy * vy);
|
||||
const capacity = (anchor: number, fraction: number) => Math.min(fraction ? anchor / fraction : Infinity, fraction < 1 ? (1 - anchor) / (1 - fraction) : Infinity);
|
||||
const ceiling = Math.min(capacity(ax, fx) / box.width, capacity(ay, fy) / box.height);
|
||||
const floor = Math.min(ceiling, Math.max(minimum / box.width, minimum / box.height));
|
||||
const factor = limit(proposed, floor, ceiling);
|
||||
const width = box.width * factor;
|
||||
const height = box.height * factor;
|
||||
return { x: ax - width * fx, y: ay - height * fy, width, height };
|
||||
}
|
||||
|
||||
export function selectAspect(box: ImageCropRect, normalizedRatio: number): ImageCropRect {
|
||||
if (!Number.isFinite(normalizedRatio) || normalizedRatio <= 0) throw new RangeError("裁剪比例无效");
|
||||
const height = Math.min(1, 1 / normalizedRatio, Math.sqrt((box.width * box.height) / normalizedRatio));
|
||||
const width = height * normalizedRatio;
|
||||
return { width, height, x: limit(box.x + (box.width - width) / 2, 0, 1 - width), y: limit(box.y + (box.height - height) / 2, 0, 1 - height) };
|
||||
}
|
||||
54
web/src/lib/canvas/image-mask-document.ts
Normal file
54
web/src/lib/canvas/image-mask-document.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type MaskPoint = readonly [number, number];
|
||||
export type MaskStroke = { diameter: number; erase: boolean; points: MaskPoint[] };
|
||||
|
||||
/** A stroke document is the source of truth; previews and exported masks are separate renders. */
|
||||
export function paintMaskStrokes(canvas: HTMLCanvasElement, strokes: readonly MaskStroke[], color: string): CanvasRenderingContext2D {
|
||||
const brush = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!brush) throw new Error("无法创建遮罩画布");
|
||||
brush.clearRect(0, 0, canvas.width, canvas.height);
|
||||
brush.lineCap = "round";
|
||||
brush.lineJoin = "round";
|
||||
brush.fillStyle = brush.strokeStyle = color;
|
||||
for (const stroke of strokes) {
|
||||
const first = stroke.points[0];
|
||||
if (!first) continue;
|
||||
brush.globalCompositeOperation = stroke.erase ? "destination-out" : "source-over";
|
||||
brush.lineWidth = stroke.diameter;
|
||||
const path = new Path2D();
|
||||
if (stroke.points.length === 1) {
|
||||
path.arc(first[0], first[1], stroke.diameter / 2, 0, Math.PI * 2);
|
||||
brush.fill(path);
|
||||
} else {
|
||||
path.moveTo(first[0], first[1]);
|
||||
for (const [x, y] of stroke.points.slice(1)) path.lineTo(x, y);
|
||||
brush.stroke(path);
|
||||
}
|
||||
}
|
||||
brush.globalCompositeOperation = "source-over";
|
||||
return brush;
|
||||
}
|
||||
|
||||
export function invertSelectionAlpha(selection: Uint8ClampedArray): Uint8ClampedArray {
|
||||
if (selection.length % 4 !== 0) throw new RangeError("遮罩像素数据无效");
|
||||
let selected = false;
|
||||
const result = new Uint8ClampedArray(selection.length).fill(255);
|
||||
for (let pixel = 0; pixel < selection.length / 4; pixel++) {
|
||||
if (selection[pixel * 4 + 3] === 0) continue;
|
||||
selected = true;
|
||||
result[pixel * 4 + 3] = 0;
|
||||
}
|
||||
if (!selected) throw new Error("请先涂抹需要修改的区域");
|
||||
return result;
|
||||
}
|
||||
|
||||
export function exportEditMask(width: number, height: number, strokes: readonly MaskStroke[]): string {
|
||||
const surface = document.createElement("canvas");
|
||||
Object.assign(surface, { width, height });
|
||||
const brush = paintMaskStrokes(surface, strokes, "black");
|
||||
const pixels = brush.getImageData(0, 0, width, height);
|
||||
pixels.data.set(invertSelectionAlpha(pixels.data));
|
||||
brush.putImageData(pixels, 0, 0);
|
||||
const png = surface.toDataURL("image/png");
|
||||
if (!png.startsWith("data:image/png")) throw new Error("遮罩导出失败");
|
||||
return png;
|
||||
}
|
||||
73
web/src/lib/canvas/image-operation-plan.ts
Normal file
73
web/src/lib/canvas/image-operation-plan.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export interface PixelSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** Normalized image coordinates, independent of the preview's CSS dimensions. */
|
||||
export interface ImageCropRect extends PixelSize {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export type ImageUpscaleAlgorithm = "nearest" | "bilinear" | "high";
|
||||
export interface ImageUpscaleParams {
|
||||
targetLongEdge: number;
|
||||
algorithm: ImageUpscaleAlgorithm;
|
||||
}
|
||||
export interface ImageSplitParams {
|
||||
rows: number;
|
||||
columns: number;
|
||||
}
|
||||
export interface ImageSplitPiece {
|
||||
row: number;
|
||||
column: number;
|
||||
dataUrl: string;
|
||||
}
|
||||
export const MAX_UPSCALE_LONG_EDGE = 4096;
|
||||
export const MAX_IMAGE_GRID = 12;
|
||||
|
||||
export function assertPixelSize(size: PixelSize): void {
|
||||
if (![size.width, size.height].every((n) => Number.isSafeInteger(n) && n > 0)) {
|
||||
throw new RangeError("图片尺寸必须是正整数");
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveUpscaleSize(width: number, height: number, requested: number): PixelSize {
|
||||
assertPixelSize({ width, height });
|
||||
if (!Number.isFinite(requested) || requested <= 0) throw new RangeError("目标尺寸无效");
|
||||
const edge = Math.max(1, Math.min(MAX_UPSCALE_LONG_EDGE, Math.round(requested)));
|
||||
return width >= height
|
||||
? { width: edge, height: Math.max(1, Math.round((height / width) * edge)) }
|
||||
: { width: Math.max(1, Math.round((width / height) * edge)), height: edge };
|
||||
}
|
||||
|
||||
export function resolveCropPixels(size: PixelSize, selection?: ImageCropRect): ImageCropRect {
|
||||
assertPixelSize(size);
|
||||
if (selection === undefined) {
|
||||
const side = Math.min(size.width, size.height);
|
||||
return { width: side, height: side, x: Math.floor((size.width - side) / 2), y: Math.floor((size.height - side) / 2) };
|
||||
}
|
||||
const { x, y, width, height } = selection;
|
||||
if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) throw new RangeError("裁剪区域无效");
|
||||
const left = Math.max(0, Math.floor(x * size.width));
|
||||
const top = Math.max(0, Math.floor(y * size.height));
|
||||
const right = Math.min(size.width, Math.ceil((x + width) * size.width));
|
||||
const bottom = Math.min(size.height, Math.ceil((y + height) * size.height));
|
||||
if (left >= right || top >= bottom) throw new RangeError("裁剪区域超出图片边界");
|
||||
return { x: left, y: top, width: right - left, height: bottom - top };
|
||||
}
|
||||
|
||||
/** Shared integer boundaries ensure every source pixel belongs to exactly one tile. */
|
||||
export function planImageTiles(size: PixelSize, grid: ImageSplitParams): Array<ImageCropRect & { row: number; column: number }> {
|
||||
assertPixelSize(size);
|
||||
if (![grid.rows, grid.columns].every((n) => Number.isInteger(n) && n >= 1 && n <= MAX_IMAGE_GRID)) throw new RangeError("切分行列数必须在 1 到 12 之间");
|
||||
if (grid.columns > size.width || grid.rows > size.height) throw new RangeError("切分数量不能超过图片像素尺寸");
|
||||
const edges = (length: number, count: number) => Array.from({ length: count + 1 }, (_, i) => Math.floor((length * i) / count));
|
||||
const horizontal = edges(size.width, grid.columns);
|
||||
const vertical = edges(size.height, grid.rows);
|
||||
return Array.from({ length: grid.rows * grid.columns }, (_, index) => {
|
||||
const column = index % grid.columns;
|
||||
const row = Math.floor(index / grid.columns);
|
||||
return { column, row, x: horizontal[column], y: vertical[row], width: horizontal[column + 1] - horizontal[column], height: vertical[row + 1] - vertical[row] };
|
||||
});
|
||||
}
|
||||
35
web/src/lib/canvas/node-resize-geometry.ts
Normal file
35
web/src/lib/canvas/node-resize-geometry.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { Position } from "@/types/canvas";
|
||||
|
||||
export type NodeResizeCorner = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
||||
export type NodeResizeSnapshot = {
|
||||
position: Position;
|
||||
width: number;
|
||||
height: number;
|
||||
minimumWidth: number;
|
||||
minimumHeight: number;
|
||||
ratio?: number;
|
||||
corner: NodeResizeCorner;
|
||||
};
|
||||
|
||||
/** Solve size first, then retain the diagonally opposite world-space anchor. */
|
||||
export function solveNodeResize(start: NodeResizeSnapshot, delta: Position): { width: number; height: number; position: Position } {
|
||||
const sx = start.corner.endsWith("left") ? -1 : 1;
|
||||
const sy = start.corner.startsWith("top") ? -1 : 1;
|
||||
let width = Math.max(start.minimumWidth, start.width + sx * delta.x);
|
||||
let height = Math.max(start.minimumHeight, start.height + sy * delta.y);
|
||||
const ratio = start.ratio;
|
||||
if (ratio !== undefined && Number.isFinite(ratio) && ratio > 0) {
|
||||
// Preserve the existing dominant-pointer-axis gesture, including horizontal ties.
|
||||
const requestedHeight = Math.abs(delta.x) >= Math.abs(delta.y) ? width / ratio : height;
|
||||
height = Math.max(start.minimumHeight, start.minimumWidth / ratio, requestedHeight);
|
||||
width = height * ratio;
|
||||
}
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
position: {
|
||||
x: start.position.x + (sx < 0 ? start.width - width : 0),
|
||||
y: start.position.y + (sy < 0 ? start.height - height : 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
35
web/src/lib/image-raster.ts
Normal file
35
web/src/lib/image-raster.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export function loadRasterImage(source: string, signal?: AbortSignal): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const picture = new Image();
|
||||
let finished = false;
|
||||
const settle = (error?: Error) => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(deadline);
|
||||
signal?.removeEventListener("abort", cancel);
|
||||
picture.onload = null;
|
||||
picture.onerror = null;
|
||||
if (error) {
|
||||
picture.src = "";
|
||||
reject(error);
|
||||
} else resolve(picture);
|
||||
};
|
||||
const cancel = () => settle(new DOMException("图片读取已取消", "AbortError"));
|
||||
const deadline = setTimeout(() => settle(new Error("图片读取超时")), 15000);
|
||||
picture.onload = () => settle(picture.naturalWidth > 0 && picture.naturalHeight > 0 ? undefined : new Error("图片尺寸无效"));
|
||||
picture.onerror = () => settle(new Error("图片加载失败,无法处理该图片"));
|
||||
signal?.addEventListener("abort", cancel, { once: true });
|
||||
if (signal?.aborted) return cancel();
|
||||
if (!source.trim()) return settle(new Error("图片地址为空"));
|
||||
try {
|
||||
const location = globalThis.location;
|
||||
if (location) {
|
||||
const address = new URL(source, location.href);
|
||||
if (["https:", "http:"].includes(address.protocol) && address.origin !== location.origin) picture.crossOrigin = "anonymous";
|
||||
}
|
||||
picture.src = source;
|
||||
} catch (error) {
|
||||
settle(error instanceof Error ? error : new Error("图片地址无效"));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,88 +1,83 @@
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import { loadRasterImage } from "./image-raster";
|
||||
|
||||
export function formatBytes(bytes: number) {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) {
|
||||
return "";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let value = bytes;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value >= 10 || unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)} ${units[unitIndex]}`;
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (!(bytes > 0) || !Number.isFinite(bytes)) return "";
|
||||
const exponent = Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
const power = Math.max(0, exponent);
|
||||
const scaled = bytes / 1024 ** power;
|
||||
return `${scaled.toFixed(power > 0 && scaled < 10 ? 1 : 0)} ${["B", "KB", "MB", "GB"][power]}`;
|
||||
}
|
||||
|
||||
export function formatDuration(ms: number) {
|
||||
const value = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(value / 60);
|
||||
const seconds = value % 60;
|
||||
return minutes ? `${minutes}分${String(seconds).padStart(2, "0")}秒` : `${seconds}秒`;
|
||||
export function formatDuration(milliseconds: number): string {
|
||||
const seconds = Number.isFinite(milliseconds) ? Math.max(0, Math.trunc(milliseconds / 1000)) : 0;
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
return `${Math.trunc(seconds / 60)}分${String(seconds % 60).padStart(2, "0")}秒`;
|
||||
}
|
||||
|
||||
export function getDataUrlByteSize(dataUrl: string) {
|
||||
const base64 = dataUrl.split(",", 2)[1];
|
||||
if (!base64) {
|
||||
return 0;
|
||||
}
|
||||
const padding = base64.endsWith("==") ? 2 : base64.endsWith("=") ? 1 : 0;
|
||||
return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
|
||||
}
|
||||
|
||||
export function readFileAsDataUrl(file: File) {
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(new Error("读取图片失败"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function readImageMeta(dataUrl: string, signal?: AbortSignal) {
|
||||
return new Promise<{ width: number; height: number; mimeType: string }>((resolve, reject) => {
|
||||
const image = new Image();
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const cleanup = (cancelLoad = false) => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
image.onload = null;
|
||||
image.onerror = null;
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
if (cancelLoad) image.src = "";
|
||||
};
|
||||
const done = (cancelLoad = false) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup(cancelLoad);
|
||||
resolve({ width: image.naturalWidth || 1024, height: image.naturalHeight || 1024, mimeType: dataUrl.match(/^data:([^;]+)/)?.[1] || "image/png" });
|
||||
};
|
||||
const onAbort = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup(true);
|
||||
reject(new DOMException("The operation was aborted", "AbortError"));
|
||||
};
|
||||
|
||||
image.onload = () => done();
|
||||
image.onerror = () => done();
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
timer = setTimeout(() => done(true), 3000);
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
function decodeDataUrl(url: string): { bytes: Uint8Array<ArrayBuffer>; mimeType: string } {
|
||||
const separator = url.indexOf(",");
|
||||
if (!url.startsWith("data:") || separator < 0) throw new Error("无效的图片数据地址");
|
||||
const parameters = url.slice(5, separator).split(";");
|
||||
const payload = url.slice(separator + 1);
|
||||
let bytes: Uint8Array<ArrayBuffer>;
|
||||
if (parameters.includes("base64")) {
|
||||
const binary = atob(payload.replace(/\s/g, ""));
|
||||
bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||
} else {
|
||||
const encoded = new TextEncoder().encode(payload);
|
||||
const output: number[] = [];
|
||||
for (let cursor = 0; cursor < encoded.length; cursor++) {
|
||||
if (encoded[cursor] !== 37) { output.push(encoded[cursor]); continue; }
|
||||
const hex = String.fromCharCode(...encoded.slice(cursor + 1, cursor + 3));
|
||||
if (!/^[0-9a-f]{2}$/i.test(hex)) throw new Error("图片数据包含无效的转义字符");
|
||||
output.push(parseInt(hex, 16)); cursor += 2;
|
||||
}
|
||||
image.src = dataUrl;
|
||||
});
|
||||
bytes = Uint8Array.from(output);
|
||||
}
|
||||
return { bytes, mimeType: parameters[0] || "" };
|
||||
}
|
||||
|
||||
export function dataUrlToFile(image: ReferenceImage) {
|
||||
const [header, content] = image.dataUrl.split(",", 2);
|
||||
const mimeType = header.match(/data:(.*?);base64/)?.[1] || image.type || "image/png";
|
||||
const binary = atob(content || "");
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return new File([bytes], image.name || "reference.png", { type: mimeType });
|
||||
export function getDataUrlByteSize(url: string): number {
|
||||
try {
|
||||
const split = url.indexOf(",");
|
||||
if (/;base64$/i.test(url.slice(0, split))) {
|
||||
const payload = url.slice(split + 1).replace(/\s/g, "");
|
||||
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) return 0;
|
||||
return Math.floor(payload.replace(/=+$/, "").length * 0.75);
|
||||
}
|
||||
return decodeDataUrl(url).bytes.byteLength;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
export async function readFileAsDataUrl(file: Blob): Promise<string> {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const chunks: string[] = [];
|
||||
for (let offset = 0; offset < bytes.length; offset += 16384) chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + 16384)));
|
||||
return `data:${file.type || "application/octet-stream"};base64,${btoa(chunks.join(""))}`;
|
||||
}
|
||||
|
||||
/** Metadata probes retain their bounded fallback; editing uses strict loadRasterImage directly. */
|
||||
export async function readImageMeta(source: string, signal?: AbortSignal): Promise<{ width: number; height: number; mimeType: string }> {
|
||||
const mimeType = /^data:([^;,]+)/.exec(source)?.[1] || "image/png";
|
||||
const request = new AbortController();
|
||||
const cancel = () => request.abort();
|
||||
const expired = setTimeout(cancel, 3000);
|
||||
signal?.addEventListener("abort", cancel, { once: true });
|
||||
if (signal?.aborted) cancel();
|
||||
try {
|
||||
const image = await loadRasterImage(source, request.signal);
|
||||
return { width: image.naturalWidth, height: image.naturalHeight, mimeType };
|
||||
} catch (failure) {
|
||||
if (signal?.aborted) throw new DOMException("图片读取已取消", "AbortError");
|
||||
return { width: 1024, height: 1024, mimeType };
|
||||
} finally {
|
||||
clearTimeout(expired);
|
||||
signal?.removeEventListener("abort", cancel);
|
||||
}
|
||||
}
|
||||
|
||||
export function dataUrlToFile(image: ReferenceImage): File {
|
||||
const decoded = decodeDataUrl(image.dataUrl);
|
||||
return new File([decoded.bytes], image.name || "reference.png", { type: decoded.mimeType || image.type || "image/png" });
|
||||
}
|
||||
|
||||
29
web/src/lib/json-event-stream.ts
Normal file
29
web/src/lib/json-event-stream.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
type EventBuffer = { buffer: string };
|
||||
|
||||
/** Feed complete JSON data frames to a protocol adapter; retain a partial tail. */
|
||||
export function consumeJsonEvents(state: EventBuffer, chunk: string, receive: (payload: unknown) => void, flush = false) {
|
||||
state.buffer += chunk;
|
||||
const input = state.buffer;
|
||||
let offset = 0;
|
||||
const boundaries = input.matchAll(/\r?\n\r?\n/g);
|
||||
for (const boundary of boundaries) {
|
||||
deliver(input.slice(offset, boundary.index), receive);
|
||||
offset = boundary.index + boundary[0].length;
|
||||
// Advance only after parsing succeeds, so an invalid frame isn't silently lost.
|
||||
state.buffer = input.slice(offset);
|
||||
}
|
||||
if (flush) {
|
||||
deliver(input.slice(offset), receive);
|
||||
state.buffer = "";
|
||||
}
|
||||
}
|
||||
|
||||
function deliver(frame: string, receive: (payload: unknown) => void) {
|
||||
const values: string[] = [];
|
||||
for (const line of frame.split(/\r?\n/)) {
|
||||
if (line.slice(0, 5) !== "data:") continue;
|
||||
values.push(line.charAt(5) === " " ? line.slice(6) : line.slice(5));
|
||||
}
|
||||
const data = values.join("\n").trim();
|
||||
if (data.length && data !== "[DONE]") receive(JSON.parse(data));
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import type { StateStorage } from "zustand/middleware";
|
||||
import { isUserScopedPersistenceSuppressed, scopedStorageKey } from "@/lib/user-scope";
|
||||
|
||||
localforage.config({
|
||||
name: "infinite-canvas",
|
||||
name: "yingce",
|
||||
storeName: "app_state",
|
||||
});
|
||||
|
||||
|
||||
56
web/src/lib/media-package.ts
Normal file
56
web/src/lib/media-package.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { createZip } from "@/lib/zip";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import { getMediaBlob } from "@/services/file-storage";
|
||||
import type { CanvasExportAsset } from "@/types/canvas-export";
|
||||
|
||||
/** UTF-8 percent encoding keeps distinct storage IDs distinct inside an archive. */
|
||||
export function packageSegment(value: string): string {
|
||||
return encodeURIComponent(value).replace(/\./g, "%2E");
|
||||
}
|
||||
export function downloadName(value: string): string {
|
||||
return value.replace(/[\x00-\x1f\\/:*?"<>|]/g, "_").trim() || "画布";
|
||||
}
|
||||
|
||||
export class MediaPackage {
|
||||
private readonly entries = new Map<string, BlobPart>();
|
||||
|
||||
put(path: string, data: BlobPart): void {
|
||||
if (this.entries.has(path)) throw new Error(`导出文件路径重复:${path}`);
|
||||
this.entries.set(path, data);
|
||||
}
|
||||
|
||||
async collect(keys: Iterable<string>, directory: string, imageKeys?: ReadonlySet<string>): Promise<CanvasExportAsset[]> {
|
||||
const assets: CanvasExportAsset[] = [];
|
||||
for (const storageKey of new Set(keys)) {
|
||||
const image = imageKeys ? imageKeys.has(storageKey) : storageKey.startsWith("image:");
|
||||
const blob = await (image ? getImageBlob(storageKey) : getMediaBlob(storageKey));
|
||||
// Cloud-backed references need not have a local blob and remain in the manifest.
|
||||
if (!blob) continue;
|
||||
const path = `${directory}/${packageSegment(storageKey)}`;
|
||||
this.put(path, blob);
|
||||
assets.push({ path, storageKey, mimeType: blob.type || "application/octet-stream", bytes: blob.size });
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
async finish(manifestPath: string, manifest: unknown): Promise<Blob> {
|
||||
this.put(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
return createZip([...this.entries].map(([name, data]) => ({ name, data })));
|
||||
}
|
||||
}
|
||||
|
||||
export function referencedStorageKeys(root: unknown): Set<string> {
|
||||
const result = new Set<string>();
|
||||
const visited = new Set<object>();
|
||||
const queue = [root];
|
||||
while (queue.length) {
|
||||
const item = queue.pop();
|
||||
if (item === null || typeof item !== "object" || visited.has(item)) continue;
|
||||
visited.add(item);
|
||||
for (const [name, child] of Object.entries(item)) {
|
||||
if (name === "storageKey" && typeof child === "string" && child.includes(":")) result.add(child);
|
||||
else if (child && typeof child === "object") queue.push(child);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { localForageStorageForScope } from "@/lib/localforage-storage";
|
||||
import type { PluginStorage } from "./plugin-types";
|
||||
|
||||
const PLUGIN_STORAGE_PREFIX = "infinite-canvas:plugin-storage:";
|
||||
const PLUGIN_STORAGE_PREFIX = "yingce:plugin-storage:";
|
||||
|
||||
export function pluginStorageFor(pluginId: string): PluginStorage {
|
||||
const storage = localForageStorageForScope();
|
||||
|
||||
@@ -3,165 +3,84 @@ import { normalizeVideoDuration, VIDEO_DURATION_OPTIONS } from "@/lib/video-gene
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
|
||||
export const SEEDANCE_REFERENCE_LIMITS = {
|
||||
images: 9,
|
||||
videos: 3,
|
||||
audios: 3,
|
||||
imageMaxBytes: 30 * 1024 * 1024,
|
||||
videoMaxBytes: 50 * 1024 * 1024,
|
||||
audioMaxBytes: 15 * 1024 * 1024,
|
||||
};
|
||||
|
||||
export const seedanceResolutionOptions = [
|
||||
{ value: "480p", label: "480P" },
|
||||
{ value: "720p", label: "720P" },
|
||||
{ value: "1080p", label: "1080P" },
|
||||
] as const;
|
||||
|
||||
// Existing channel contract values; these are not a claim about every provider's current limits.
|
||||
export const SEEDANCE_REFERENCE_LIMITS = { images: 9, videos: 3, audios: 3, imageMaxBytes: 30 * 1048576, videoMaxBytes: 50 * 1048576, audioMaxBytes: 15 * 1048576 };
|
||||
export const seedanceResolutionOptions = [{ label: "480P", value: "480p" }, { label: "720P", value: "720p" }, { label: "1080P", value: "1080p" }] as const;
|
||||
export const seedanceRatioOptions = [
|
||||
{ value: "16:9", label: "横屏" },
|
||||
{ value: "9:16", label: "竖屏" },
|
||||
{ value: "1:1", label: "方形" },
|
||||
{ value: "4:3", label: "标准横屏" },
|
||||
{ value: "3:4", label: "标准竖屏" },
|
||||
{ value: "21:9", label: "宽银幕" },
|
||||
{ value: "adaptive", label: "自适应" },
|
||||
{ label: "横屏", value: "16:9" }, { label: "竖屏", value: "9:16" }, { label: "方形", value: "1:1" },
|
||||
{ label: "标准横屏", value: "4:3" }, { label: "标准竖屏", value: "3:4" }, { label: "宽银幕", value: "21:9" }, { label: "自适应", value: "adaptive" },
|
||||
] as const;
|
||||
|
||||
export const seedanceDurationOptions = VIDEO_DURATION_OPTIONS;
|
||||
|
||||
const seedancePixels = {
|
||||
"480p": {
|
||||
"16:9": "864x496",
|
||||
"4:3": "752x560",
|
||||
"1:1": "640x640",
|
||||
"3:4": "560x752",
|
||||
"9:16": "496x864",
|
||||
"21:9": "992x432",
|
||||
},
|
||||
"720p": {
|
||||
"16:9": "1280x720",
|
||||
"4:3": "1112x834",
|
||||
"1:1": "960x960",
|
||||
"3:4": "834x1112",
|
||||
"9:16": "720x1280",
|
||||
"21:9": "1470x630",
|
||||
},
|
||||
"1080p": {
|
||||
"16:9": "1920x1080",
|
||||
"4:3": "1664x1248",
|
||||
"1:1": "1440x1440",
|
||||
"3:4": "1248x1664",
|
||||
"9:16": "1080x1920",
|
||||
"21:9": "2206x946",
|
||||
},
|
||||
} as const;
|
||||
const rasterRows: Record<string, readonly string[]> = {
|
||||
"16:9": ["864x496", "1280x720", "1920x1080"], "4:3": ["752x560", "1112x834", "1664x1248"],
|
||||
"1:1": ["640x640", "960x960", "1440x1440"], "3:4": ["560x752", "834x1112", "1248x1664"],
|
||||
"9:16": ["496x864", "720x1280", "1080x1920"], "21:9": ["992x432", "1470x630", "2206x946"],
|
||||
};
|
||||
const ratios = Object.keys(rasterRows).map((label) => { const [w, h] = label.split(":").map(Number); return { label, ratio: w / h }; });
|
||||
|
||||
export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "baseUrl">) {
|
||||
const requestConfig = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config;
|
||||
// Agent Plan 图片与视频共用 /api/plan/v3;按协议排除图片,避免 Seedream 误判为 Seedance。
|
||||
if ("interfaceType" in requestConfig) {
|
||||
const interfaceType = requestConfig.interfaceType;
|
||||
if (interfaceType === "volcengine-ark-image" || interfaceType === "volcengine-ark-agent-plan-image") return false;
|
||||
export function isSeedanceVideoModel(model: string): boolean { return /seedance/i.test(model); }
|
||||
export function isSeedanceFastModel(model: string): boolean { return isSeedanceVideoModel(model) && /fast/i.test(model); }
|
||||
export function isArkPlanBaseUrl(baseUrl: string): boolean { return /\/api\/plan\/v3/i.test(baseUrl); }
|
||||
|
||||
export function isSeedanceVideoConfig(config: AiConfig | Pick<AiConfig, "model" | "videoModel" | "baseUrl">): boolean {
|
||||
const request = "channels" in config ? resolveModelRequestConfig(config, config.model || config.videoModel) : config;
|
||||
if ("interfaceType" in request && ["volcengine-ark-image", "volcengine-ark-agent-plan-image"].includes(String(request.interfaceType))) return false;
|
||||
return isArkPlanBaseUrl(request.baseUrl || "") || isSeedanceVideoModel(modelOptionName(request.model || request.videoModel));
|
||||
}
|
||||
|
||||
export function normalizeResolutionToken(value: string): string {
|
||||
const token = String(value || "").toLowerCase();
|
||||
const aliases: Record<string, string> = { low: "480p", auto: "720p", high: "720p", medium: "720p", "4k": "2160p", "": "720p" };
|
||||
return aliases[token] || `${token.replace(/p$/, "")}p`;
|
||||
}
|
||||
export function normalizeSeedanceResolution(value: string, model = ""): string {
|
||||
const requested = normalizeResolutionToken(value);
|
||||
const supported = isSeedanceFastModel(model) ? ["480p", "720p"] : ["480p", "720p", "1080p", "2160p"];
|
||||
return supported.includes(requested) ? requested : "720p";
|
||||
}
|
||||
export function normalizeSeedanceDuration(value: string): number { return +normalizeVideoDuration(value); }
|
||||
|
||||
export function normalizeSeedanceRatio(value: string): string {
|
||||
if (Object.hasOwn(rasterRows, value)) return value;
|
||||
const size = /^(\d+)x(\d+)$/.exec(value);
|
||||
if (!size || +size[1] === 0 || +size[2] === 0) return "adaptive";
|
||||
const requested = +size[1] / +size[2];
|
||||
let nearest = ratios[0];
|
||||
let distance = Infinity;
|
||||
for (const entry of ratios) {
|
||||
const next = Math.abs(entry.ratio - requested);
|
||||
if (next < distance) { distance = next; nearest = entry; }
|
||||
}
|
||||
return isSeedanceVideoModel(modelOptionName(requestConfig.model || requestConfig.videoModel)) || isArkPlanBaseUrl(requestConfig.baseUrl || "");
|
||||
return nearest.label;
|
||||
}
|
||||
|
||||
export function isSeedanceVideoModel(model: string) {
|
||||
const value = model.toLowerCase();
|
||||
return value.includes("seedance") || value.includes("doubao-seedance");
|
||||
export function seedancePixelLabel(resolution: string, ratio: string): string {
|
||||
const aspect = normalizeSeedanceRatio(ratio);
|
||||
if (aspect === "adaptive") return "自动匹配";
|
||||
const column = ["480p", "720p", "1080p"].indexOf(normalizeSeedanceResolution(resolution));
|
||||
return column < 0 ? "" : rasterRows[aspect]?.[column] || "";
|
||||
}
|
||||
|
||||
export function isSeedanceFastModel(model: string) {
|
||||
const value = model.toLowerCase();
|
||||
return isSeedanceVideoModel(value) && value.includes("fast");
|
||||
export function boolConfig(value: string | undefined, fallback: boolean): boolean { return value === "true" || (value !== "false" && fallback); }
|
||||
export function seedanceReferenceLabel(kind: "image" | "video" | "audio", index: number): string {
|
||||
return `${({ image: "图片", video: "视频", audio: "音频" })[kind]}${index + 1}`;
|
||||
}
|
||||
export function buildSeedancePromptText(prompt: string, _images: ReferenceImage[], _videos: ReferenceVideo[], _audios: ReferenceAudio[]): string { return prompt.trim(); }
|
||||
|
||||
export function isArkPlanBaseUrl(baseUrl: string) {
|
||||
return baseUrl.toLowerCase().includes("ark.cn-beijing.volces.com/api/plan/v3") || baseUrl.toLowerCase().includes("/api/plan/v3");
|
||||
}
|
||||
|
||||
export function normalizeSeedanceResolution(value: string, model = "") {
|
||||
const normalized = normalizeResolutionToken(value);
|
||||
if (isSeedanceFastModel(model) && (normalized === "1080p" || normalized === "2160p")) return "720p";
|
||||
return normalized === "2160p" || seedanceResolutionOptions.some((item) => item.value === normalized) ? normalized : "720p";
|
||||
}
|
||||
|
||||
export function normalizeResolutionToken(value: string) {
|
||||
if (value === "low") return "480p";
|
||||
if (value === "auto" || value === "high" || value === "medium") return "720p";
|
||||
if (value.toLowerCase() === "4k") return "2160p";
|
||||
const resolution = String(value || "").replace(/p$/i, "") || "720";
|
||||
return `${resolution}p`;
|
||||
}
|
||||
|
||||
export function normalizeSeedanceDuration(value: string) {
|
||||
return Number(normalizeVideoDuration(value));
|
||||
}
|
||||
|
||||
export function normalizeSeedanceRatio(value: string) {
|
||||
if (!value || value === "auto" || value === "adaptive") return "adaptive";
|
||||
if (seedanceRatioOptions.some((item) => item.value === value)) return value;
|
||||
const match = value.match(/^(\d+)x(\d+)$/);
|
||||
if (!match) return "adaptive";
|
||||
const width = Number(match[1]);
|
||||
const height = Number(match[2]);
|
||||
if (!width || !height) return "adaptive";
|
||||
const ratio = width / height;
|
||||
const options = [
|
||||
["16:9", 16 / 9],
|
||||
["4:3", 4 / 3],
|
||||
["1:1", 1],
|
||||
["3:4", 3 / 4],
|
||||
["9:16", 9 / 16],
|
||||
["21:9", 21 / 9],
|
||||
] as const;
|
||||
return options.reduce((best, item) => (Math.abs(item[1] - ratio) < Math.abs(best[1] - ratio) ? item : best), options[0])[0];
|
||||
}
|
||||
|
||||
export function seedancePixelLabel(resolution: string, ratio: string) {
|
||||
const normalizedResolution = normalizeSeedanceResolution(resolution) as keyof typeof seedancePixels;
|
||||
const normalizedRatio = normalizeSeedanceRatio(ratio) as keyof (typeof seedancePixels)[typeof normalizedResolution] | "adaptive";
|
||||
if (normalizedRatio === "adaptive") return "自动匹配";
|
||||
return seedancePixels[normalizedResolution][normalizedRatio] || "";
|
||||
}
|
||||
|
||||
export function boolConfig(value: string | undefined, fallback: boolean) {
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function seedanceReferenceLabel(kind: "image" | "video" | "audio", index: number) {
|
||||
if (kind === "image") return `图片${index + 1}`;
|
||||
if (kind === "video") return `视频${index + 1}`;
|
||||
return `音频${index + 1}`;
|
||||
}
|
||||
|
||||
export function buildSeedancePromptText(prompt: string, _images: ReferenceImage[], _videos: ReferenceVideo[], _audios: ReferenceAudio[]) {
|
||||
return prompt.trim();
|
||||
}
|
||||
|
||||
export function seedanceVideoReferenceError(videos: ReferenceVideo[]) {
|
||||
let totalDurationMs = 0;
|
||||
for (let index = 0; index < videos.length; index += 1) {
|
||||
const video = videos[index];
|
||||
const label = seedanceReferenceLabel("video", index);
|
||||
if (video.bytes && video.bytes > SEEDANCE_REFERENCE_LIMITS.videoMaxBytes) return `${label} 超过 50MB,请压缩后再上传`;
|
||||
if (video.durationMs) {
|
||||
if (video.durationMs < 2000 || video.durationMs > 15000) return `${label} 时长需要在 2-15 秒之间`;
|
||||
totalDurationMs += video.durationMs;
|
||||
}
|
||||
if (video.width && video.height) {
|
||||
if (video.width < 300 || video.width > 6000 || video.height < 300 || video.height > 6000) return `${label} 宽高需要在 300-6000px 之间`;
|
||||
const ratio = video.width / video.height;
|
||||
if (ratio < 0.4 || ratio > 2.5) return `${label} 宽高比需要在 0.4-2.5 之间`;
|
||||
const pixels = video.width * video.height;
|
||||
if (pixels < 640 * 640 || pixels > 2206 * 946) return `${label} 像素总量不符合 Seedance 要求,请转成 480p/720p/1080p 后再上传`;
|
||||
}
|
||||
type VideoRule = { valid: (video: ReferenceVideo) => boolean; message: string };
|
||||
const videoRules: VideoRule[] = [
|
||||
{ valid: (v) => !v.bytes || v.bytes <= SEEDANCE_REFERENCE_LIMITS.videoMaxBytes, message: "超过 50MB,请压缩后再上传" },
|
||||
{ valid: (v) => !v.durationMs || (v.durationMs >= 2000 && v.durationMs <= 15000), message: "时长需要在 2-15 秒之间" },
|
||||
{ valid: (v) => !v.width || !v.height || [v.width, v.height].every((edge) => edge >= 300 && edge <= 6000), message: "宽高需要在 300-6000px 之间" },
|
||||
{ valid: (v) => !v.width || !v.height || (v.width / v.height >= 0.4 && v.width / v.height <= 2.5), message: "宽高比需要在 0.4-2.5 之间" },
|
||||
{ valid: (v) => !v.width || !v.height || (v.width * v.height >= 409600 && v.width * v.height <= 2086876), message: "像素总量不符合 Seedance 要求,请转成 480p/720p/1080p 后再上传" },
|
||||
];
|
||||
export function seedanceVideoReferenceError(videos: ReferenceVideo[]): string {
|
||||
for (const [index, video] of videos.entries()) {
|
||||
const failed = videoRules.find((rule) => !rule.valid(video));
|
||||
if (failed) return `${seedanceReferenceLabel("video", index)} ${failed.message}`;
|
||||
}
|
||||
if (totalDurationMs > 15000) return "Seedance 参考视频总时长不能超过 15 秒";
|
||||
return "";
|
||||
const duration = videos.reduce((total, video) => total + (video.durationMs || 0), 0);
|
||||
return duration > 15000 ? "Seedance 参考视频总时长不能超过 15 秒" : "";
|
||||
}
|
||||
|
||||
export const seedanceVideoReferenceHint = "参考视频需为 mp4/mov,H.264/H.265,FPS 24-60;含真人人脸素材请使用火山授权 asset:// 素材。";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const ACTIVE_USER_SCOPE_KEY = "infinite-canvas:active-user-scope";
|
||||
const ACTIVE_USER_SCOPE_KEY = "yingce:active-user-scope";
|
||||
const GUEST_SCOPE = "guest";
|
||||
let userScopedPersistenceSuppressionDepth = 0;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user