feat(agent): 创作助手 - 完成第一期交互与执行基线
This commit is contained in:
1 parent
bacd502f5c
commit
abb5023766
54 files changed
+6953
-323
No files matched your search
@@ -0,0 +1,89 @@
|
||||
import { imageToDataUrl } from "@/services/image-storage";
|
||||
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||||
import { getActiveUserScope } from "@/lib/user-scope";
|
||||
|
||||
// 预算只作用于视觉分析副本,不修改原素材。
|
||||
export async function agentImagePreviews(images: Parameters<typeof imageToDataUrl>[0][]): Promise<string[]> {
|
||||
if (!images.length) return [];
|
||||
const limit = Math.min(256 * 1024, Math.floor(2 * 1024 * 1024 / images.length));
|
||||
if (limit < 16 * 1024) throw new Error("本次参考图片过多,请分批选择图片后继续。");
|
||||
const previews: string[] = [];
|
||||
// 顺序解码,避免多张原图同时占用内存。
|
||||
for (const source of images) {
|
||||
const url = await imageToDataUrl(source);
|
||||
if (!url) throw new Error("参考图片无法读取,请重新选择图片。");
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
try { await image.decode(); }
|
||||
catch { throw new Error("参考图片无法解码,请重新选择图片。"); }
|
||||
const canvas = document.createElement("canvas");
|
||||
try {
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("无法准备看图预览,请刷新后重试。");
|
||||
let edge = Math.min(1600, Math.max(image.naturalWidth, image.naturalHeight));
|
||||
let preview = "";
|
||||
while (edge > 0) {
|
||||
const scale = Math.min(1, edge / Math.max(image.naturalWidth, image.naturalHeight));
|
||||
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
preview = canvas.toDataURL("image/jpeg", 0.82);
|
||||
if (preview.length <= limit) break;
|
||||
if (edge <= 128) break;
|
||||
edge = Math.floor(edge * 0.75);
|
||||
}
|
||||
if (!preview || preview.length > limit) throw new Error("参考图片体积过大,请减少本次选择的图片后继续。");
|
||||
previews.push(preview);
|
||||
} finally {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
image.src = "";
|
||||
}
|
||||
}
|
||||
return previews;
|
||||
}
|
||||
|
||||
type AgentImageCrop = { x: number; y: number; width: number; height: number };
|
||||
|
||||
export async function inspectAgentImage(source: Parameters<typeof imageToDataUrl>[0], detail: "preview" | "original" | "crop", crop?: AgentImageCrop) {
|
||||
const scope = getActiveUserScope();
|
||||
const dataUrl = await encodeAgentImage(source, detail, crop);
|
||||
if (scope !== getActiveUserScope()) throw new DOMException("账号已切换", "AbortError");
|
||||
if (detail === "original" && resourceIdFromStorageKey(source.storageKey)) return { storageKey: source.storageKey!, encodedBytes: dataUrl.length };
|
||||
const response = await fetch(dataUrl);
|
||||
if (!response.ok) throw new Error("读取看图素材失败");
|
||||
const blob = await response.blob();
|
||||
const digest = await crypto.subtle.digest("SHA-256", await blob.arrayBuffer());
|
||||
const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
if (scope !== getActiveUserScope()) throw new DOMException("账号已切换", "AbortError");
|
||||
const resource = await uploadResourceFile(blob, "image", { fileName: `agent-inspect-${hash.slice(0, 12)}.${blob.type === "image/png" ? "png" : "jpg"}`, idempotencyKey: `agent-inspect:${hash}` });
|
||||
if (scope !== getActiveUserScope()) throw new DOMException("账号已切换", "AbortError");
|
||||
return { storageKey: resourceStorageKey(resource.id), encodedBytes: dataUrl.length };
|
||||
}
|
||||
|
||||
async function encodeAgentImage(source: Parameters<typeof imageToDataUrl>[0], detail: "preview" | "original" | "crop", crop?: AgentImageCrop) {
|
||||
if (detail === "preview") return (await agentImagePreviews([source]))[0];
|
||||
const url = await imageToDataUrl(source);
|
||||
if (!url) throw new Error("参考图片无法读取,请重新选择图片。");
|
||||
if (detail === "original") {
|
||||
if (url.length > 6 * 1024 * 1024) throw new Error("原图超过本次看图预算,请改用 crop 分区检查,不要降低细节精度。");
|
||||
return url;
|
||||
}
|
||||
if (!crop || ![crop.x, crop.y, crop.width, crop.height].every((value) => typeof value === "number" && Number.isFinite(value)) || crop.x < 0 || crop.y < 0 || crop.width <= 0 || crop.height <= 0 || crop.x + crop.width > 1 || crop.y + crop.height > 1) throw new Error("裁剪范围必须是 0 到 1 的归一化坐标,且完整位于原图内。");
|
||||
const image = new Image();
|
||||
image.src = url;
|
||||
await image.decode();
|
||||
const canvas = document.createElement("canvas");
|
||||
try {
|
||||
canvas.width = Math.max(1, Math.round(image.naturalWidth * crop.width));
|
||||
canvas.height = Math.max(1, Math.round(image.naturalHeight * crop.height));
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("无法读取图片局部,请刷新后重试。");
|
||||
context.drawImage(image, image.naturalWidth * crop.x, image.naturalHeight * crop.y, image.naturalWidth * crop.width, image.naturalHeight * crop.height, 0, 0, canvas.width, canvas.height);
|
||||
const result = canvas.toDataURL("image/png");
|
||||
if (result.length > 6 * 1024 * 1024) throw new Error("该区域超过本次看图预算,请缩小裁剪范围后继续检查。");
|
||||
return result;
|
||||
} finally { canvas.width = 0; canvas.height = 0; image.src = ""; }
|
||||
}
|
||||
Reference in new issue
Block a user