215 lines
7.1 KiB
TypeScript
215 lines
7.1 KiB
TypeScript
import {
|
|
REFERENCE_IMAGE_COMPRESS_THRESHOLD,
|
|
REFERENCE_IMAGE_MAX_DIMENSION,
|
|
REFERENCE_IMAGE_INITIAL_QUALITY,
|
|
REFERENCE_IMAGE_MIN_QUALITY,
|
|
type WorkbenchMode,
|
|
type ReferenceKind,
|
|
type ReferenceItem,
|
|
} from "./workbenchConstants";
|
|
import { resolvePreUploadedUrl } from "../../api/referenceUploadService";
|
|
|
|
export function getRatioOptionClassName(value: string) {
|
|
return `ai-workbench-ratio-option__preview--${value.replace(":", "-")}`;
|
|
}
|
|
|
|
export function getSettingsGridColumnsClassName(columns: 2 | 3 | 4 = 3) {
|
|
return `ai-workbench-settings-panel__grid--cols-${columns}`;
|
|
}
|
|
|
|
export function getReferenceAccept(mode: WorkbenchMode, videoFrameMode?: string) {
|
|
if (mode === "chat") return ".docx,.txt,.md,.xlsx,.xls,.png,.jpg,.jpeg,.gif,.webp";
|
|
if (mode === "image") return "image/*";
|
|
if (videoFrameMode === "start-end") return "image/*";
|
|
return "image/*,video/mp4,video/quicktime,video/webm,video/x-msvideo,.mp4,.mov,.webm,.avi,audio/mpeg,audio/mp3,audio/wav,audio/x-wav,.mp3,.wav";
|
|
}
|
|
|
|
export function getReferenceUploadLabel(mode: WorkbenchMode) {
|
|
if (mode === "video") return "参考内容";
|
|
if (mode === "image") return "参考图";
|
|
return "附件";
|
|
}
|
|
|
|
export function getReferenceLimit(mode: WorkbenchMode, videoFrameMode?: string) {
|
|
if (mode === "video" && videoFrameMode === "start-end") return 2;
|
|
if (mode === "video") return 12;
|
|
if (mode === "image") return 9;
|
|
return 4;
|
|
}
|
|
|
|
export function getReferenceKindLabel(kind: ReferenceKind) {
|
|
if (kind === "image") return "图片";
|
|
if (kind === "video") return "视频";
|
|
if (kind === "audio") return "音频";
|
|
return "附件";
|
|
}
|
|
|
|
export function getReferenceEmptyCopy(mode: WorkbenchMode) {
|
|
if (mode === "video") return "上传最多12个参考素材,首尾帧模式仅保留2张图片,输入文字或 @ 引用内容,自由组合图、文、音、视频多元素";
|
|
if (mode === "image") return "最多上传9张参考图,输入文字或 @ 引用内容,控制角色、风格和构图";
|
|
return "上传附件后可用 @ 引用,帮助 Agent 读取上下文";
|
|
}
|
|
|
|
export function hexToRgbTriplet(hex: string) {
|
|
const normalized = hex.replace("#", "");
|
|
const full = normalized.length === 3
|
|
? normalized
|
|
.split("")
|
|
.map((char) => `${char}${char}`)
|
|
.join("")
|
|
: normalized;
|
|
|
|
const value = Number.parseInt(full, 16);
|
|
const r = (value >> 16) & 255;
|
|
const g = (value >> 8) & 255;
|
|
const b = value & 255;
|
|
return `${r}, ${g}, ${b}`;
|
|
}
|
|
|
|
export function inferReferenceKind(file: File, mode: WorkbenchMode): ReferenceKind {
|
|
if (file.type.startsWith("image/")) return "image";
|
|
if (file.type.startsWith("video/")) return "video";
|
|
if (file.type.startsWith("audio/")) return "audio";
|
|
return mode === "chat" ? "file" : "image";
|
|
}
|
|
|
|
export function disposeReferencePreview(item: Pick<ReferenceItem, "previewUrl">) {
|
|
if (item.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
|
}
|
|
|
|
export function fileToDataUrl(file: File) {
|
|
return new Promise<string>((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.onload = () => {
|
|
if (typeof reader.result === "string") {
|
|
resolve(reader.result);
|
|
} else {
|
|
reject(new Error("Unable to read reference file"));
|
|
}
|
|
};
|
|
reader.onerror = () => reject(reader.error || new Error("Unable to read reference file"));
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
export function bytesToHex(buffer: ArrayBuffer) {
|
|
return Array.from(new Uint8Array(buffer))
|
|
.map((byte) => byte.toString(16).padStart(2, "0"))
|
|
.join("");
|
|
}
|
|
|
|
export async function buildReferenceFingerprint(file: File, kind: ReferenceKind) {
|
|
if (kind === "image" && window.crypto?.subtle) {
|
|
const digest = await window.crypto.subtle.digest("SHA-256", await file.arrayBuffer());
|
|
return `image:${bytesToHex(digest)}`;
|
|
}
|
|
return `${kind}:${file.name}:${file.size}:${file.lastModified}:${file.type}`;
|
|
}
|
|
|
|
export function canCompressReferenceImage(file: File) {
|
|
return (
|
|
file.size > REFERENCE_IMAGE_COMPRESS_THRESHOLD &&
|
|
file.type.startsWith("image/") &&
|
|
!/svg|gif/i.test(file.type)
|
|
);
|
|
}
|
|
|
|
function canvasToBlob(canvas: HTMLCanvasElement, type: string, quality: number) {
|
|
return new Promise<Blob | null>((resolve) => {
|
|
canvas.toBlob(resolve, type, quality);
|
|
});
|
|
}
|
|
|
|
function getCompressedImageName(fileName: string) {
|
|
const baseName = fileName.replace(/\.[^.]+$/, "");
|
|
return `${baseName || "reference"}.jpg`;
|
|
}
|
|
|
|
export async function compressReferenceImageIfNeeded(file: File) {
|
|
if (!canCompressReferenceImage(file)) {
|
|
return { file, compressed: false };
|
|
}
|
|
|
|
try {
|
|
const bitmap = await createImageBitmap(file);
|
|
const scale = Math.min(1, REFERENCE_IMAGE_MAX_DIMENSION / Math.max(bitmap.width, bitmap.height));
|
|
let width = Math.max(1, Math.round(bitmap.width * scale));
|
|
let height = Math.max(1, Math.round(bitmap.height * scale));
|
|
const canvas = document.createElement("canvas");
|
|
const context = canvas.getContext("2d");
|
|
if (!context) {
|
|
bitmap.close();
|
|
return { file, compressed: false };
|
|
}
|
|
|
|
const render = () => {
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
context.fillStyle = "#ffffff";
|
|
context.fillRect(0, 0, width, height);
|
|
context.drawImage(bitmap, 0, 0, width, height);
|
|
};
|
|
|
|
const encode = async () => {
|
|
let quality = REFERENCE_IMAGE_INITIAL_QUALITY;
|
|
let nextBlob = await canvasToBlob(canvas, "image/jpeg", quality);
|
|
while (nextBlob && nextBlob.size > REFERENCE_IMAGE_COMPRESS_THRESHOLD && quality > REFERENCE_IMAGE_MIN_QUALITY) {
|
|
quality = Math.max(REFERENCE_IMAGE_MIN_QUALITY, quality - 0.08);
|
|
nextBlob = await canvasToBlob(canvas, "image/jpeg", quality);
|
|
}
|
|
return nextBlob;
|
|
};
|
|
|
|
render();
|
|
let blob = await encode();
|
|
while (blob && blob.size > REFERENCE_IMAGE_COMPRESS_THRESHOLD && Math.max(width, height) > 960) {
|
|
width = Math.max(1, Math.round(width * 0.82));
|
|
height = Math.max(1, Math.round(height * 0.82));
|
|
render();
|
|
blob = await encode();
|
|
}
|
|
bitmap.close();
|
|
|
|
if (!blob || blob.size >= file.size) {
|
|
return { file, compressed: false };
|
|
}
|
|
|
|
return {
|
|
file: new File([blob], getCompressedImageName(file.name), {
|
|
type: "image/jpeg",
|
|
lastModified: file.lastModified,
|
|
}),
|
|
compressed: true,
|
|
};
|
|
} catch {
|
|
return { file, compressed: false };
|
|
}
|
|
}
|
|
|
|
export function buildReferenceToken(kind: ReferenceKind, index: number) {
|
|
if (kind === "image") return `@图片${index}`;
|
|
if (kind === "video") return `@视频${index}`;
|
|
if (kind === "audio") return `@音频${index}`;
|
|
return `@附件${index}`;
|
|
}
|
|
|
|
export async function resolveReferenceUrls(items: ReferenceItem[]): Promise<string[]> {
|
|
const tasks = items.map(async (item) => {
|
|
if (item.remoteUrl) return item.remoteUrl;
|
|
if (!item.file) {
|
|
if (item.previewUrl && /^https?:\/\//i.test(item.previewUrl)) {
|
|
return item.previewUrl;
|
|
}
|
|
return null;
|
|
}
|
|
const url = await resolvePreUploadedUrl(item.file, item.name, item.fingerprint);
|
|
if (url) {
|
|
item.remoteUrl = url;
|
|
return url;
|
|
}
|
|
return null;
|
|
});
|
|
const results = await Promise.all(tasks);
|
|
return results.filter((url): url is string => url !== null);
|
|
}
|