完善生图5.0的功能

This commit is contained in:
andy committed 2026-08-25 14:06:04 +08:00
1 parent 88a4268d40
commit 678d213f40
31 files changed
+3281 -107

No files matched your search

+10
View File
@@ -2,10 +2,20 @@ export function assetPreviewUrl(asset: { id: string }) {
return `/api/assets/${encodeURIComponent(asset.id)}/download?inline=1`;
}
// Binary reads stay on the application origin so browser fetch/canvas callers
// do not follow a private-OSS preview redirect and require bucket CORS access.
export function assetContentUrl(asset: { id: string }) {
return `/api/assets/${encodeURIComponent(asset.id)}/download`;
}
export function materialPreviewUrl(material: { id?: string; url: string }) {
return material.id ? assetPreviewUrl({ id: material.id }) : material.url;
}
export function materialContentUrl(material: { id?: string; url: string }) {
return material.id ? assetContentUrl({ id: material.id }) : material.url;
}
export function storedAssetPreviewUrl(url: string | undefined, assets: Array<{ id: string; url: string }>) {
if (!url) return "";
const asset = assets.find((item) => item.url === url);
+84
View File
@@ -0,0 +1,84 @@
export type SeedreamLayerCompositionItem = {
url: string;
visible: boolean;
box: [number, number, number, number];
};
export async function downloadSeedreamLayerComposition(options: {
items: SeedreamLayerCompositionItem[];
width?: number;
height?: number;
fileName: string;
}) {
if (!options.items.some((item) => item.visible)) throw new Error("请至少显示一个图层后再导出");
const decoded: Array<{ item: SeedreamLayerCompositionItem; bitmap: ImageBitmap }> = [];
try {
for (const item of options.items) decoded.push({ item, bitmap: await fetchBitmap(item.url) });
const reference = decoded[0].bitmap;
const width = positiveDimension(options.width) || reference.width;
const height = positiveDimension(options.height) || reference.height;
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
if (!context) throw new Error("当前浏览器无法创建合成画布");
context.clearRect(0, 0, width, height);
for (const { item, bitmap } of decoded) {
if (!item.visible) continue;
const [left, top, right, bottom] = item.box;
context.drawImage(
bitmap,
left / 1000 * width,
top / 1000 * height,
Math.max(1, (right - left) / 1000 * width),
Math.max(1, (bottom - top) / 1000 * height)
);
}
const blob = await canvasBlob(canvas, "image/png");
downloadBlob(blob, options.fileName);
} finally {
for (const { bitmap } of decoded) bitmap.close();
}
}
async function fetchBitmap(url: string) {
const response = await fetch(sameOriginBinaryURL(url), { credentials: "same-origin" });
if (!response.ok) throw new Error(`读取图层失败(HTTP ${response.status})`);
return createImageBitmap(await response.blob());
}
function sameOriginBinaryURL(url: string) {
if (!url.startsWith("/api/assets/") || !url.includes("/download")) return url;
const [path, rawQuery = ""] = url.split("?", 2);
const query = new URLSearchParams(rawQuery);
query.delete("inline");
const suffix = query.toString();
return suffix ? `${path}?${suffix}` : path;
}
function canvasBlob(canvas: HTMLCanvasElement, type: string) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("生成合成图片失败"));
}, type);
});
}
function downloadBlob(blob: Blob, fileName: string) {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
function positiveDimension(value?: number) {
return value && Number.isFinite(value) && value > 1 ? Math.round(value) : undefined;
}
+56
View File
@@ -0,0 +1,56 @@
import { materialContentUrl } from "@/lib/client/asset-urls";
import type { PromptMaterial } from "@/lib/prompt/assembler";
import type { SeedreamAnnotation } from "@/lib/seedream/creation";
const markerColor = "#2563eb";
export async function createSeedreamMarkedFile(
source: PromptMaterial,
annotations: SeedreamAnnotation[],
imageIndex: number
) {
const strokes = annotations.filter((annotation) => annotation.kind === "stroke" && annotation.points && annotation.points.length > 1);
if (!strokes.length) return null;
const response = await fetch(materialContentUrl(source), { credentials: "same-origin" });
if (!response.ok) throw new Error(`读取图${imageIndex}失败,无法合成蓝色标记`);
const bitmap = await createImageBitmap(await response.blob());
try {
const canvas = document.createElement("canvas");
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext("2d");
if (!context) throw new Error("当前浏览器无法创建标记画布");
context.drawImage(bitmap, 0, 0);
context.strokeStyle = markerColor;
context.fillStyle = markerColor;
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = Math.max(5, Math.round(Math.min(bitmap.width, bitmap.height) * 0.012));
for (const stroke of strokes) {
const points = stroke.points || [];
if (!points.length) continue;
context.beginPath();
context.moveTo(points[0].x / 1000 * bitmap.width, points[0].y / 1000 * bitmap.height);
for (const point of points.slice(1)) {
context.lineTo(point.x / 1000 * bitmap.width, point.y / 1000 * bitmap.height);
}
context.stroke();
}
const blob = await canvasBlob(canvas, "image/png");
return new File([blob], `seedream-marked-image-${imageIndex}.png`, { type: "image/png" });
} finally {
bitmap.close();
}
}
function canvasBlob(canvas: HTMLCanvasElement, type: string) {
return new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error("生成标记图片失败"));
}, type);
});
}
+121
View File
@@ -0,0 +1,121 @@
type DownloadableFile = {
name: string;
url: string;
};
type ZipEntry = {
name: Uint8Array;
data: Uint8Array;
crc: number;
offset: number;
};
export async function downloadFilesAsZip(files: DownloadableFile[], archiveName: string) {
if (!files.length) return;
const usedNames = new Set<string>();
const entries: ZipEntry[] = [];
for (let index = 0; index < files.length; index += 1) {
const file = files[index];
const response = await fetch(file.url, { credentials: "same-origin" });
if (!response.ok) throw new Error(`下载第 ${index + 1} 个图层失败`);
const data = new Uint8Array(await response.arrayBuffer());
const safeName = uniqueFileName(sanitizeFileName(file.name) || `layer-${index}.png`, usedNames);
entries.push({ name: new TextEncoder().encode(safeName), data, crc: crc32(data), offset: 0 });
}
const blob = zipBlob(entries);
const href = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = href;
anchor.download = sanitizeFileName(archiveName) || "seedream-layers.zip";
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
window.setTimeout(() => URL.revokeObjectURL(href), 1000);
}
function zipBlob(entries: ZipEntry[]) {
const localParts: Uint8Array[] = [];
let offset = 0;
for (const entry of entries) {
entry.offset = offset;
const header = new Uint8Array(30 + entry.name.length);
const view = new DataView(header.buffer);
view.setUint32(0, 0x04034b50, true);
view.setUint16(4, 20, true);
view.setUint16(6, 0x0800, true);
view.setUint16(8, 0, true);
view.setUint32(14, entry.crc, true);
view.setUint32(18, entry.data.length, true);
view.setUint32(22, entry.data.length, true);
view.setUint16(26, entry.name.length, true);
header.set(entry.name, 30);
localParts.push(header, entry.data);
offset += header.length + entry.data.length;
}
const centralOffset = offset;
const centralParts: Uint8Array[] = [];
for (const entry of entries) {
const header = new Uint8Array(46 + entry.name.length);
const view = new DataView(header.buffer);
view.setUint32(0, 0x02014b50, true);
view.setUint16(4, 20, true);
view.setUint16(6, 20, true);
view.setUint16(8, 0x0800, true);
view.setUint16(10, 0, true);
view.setUint32(16, entry.crc, true);
view.setUint32(20, entry.data.length, true);
view.setUint32(24, entry.data.length, true);
view.setUint16(28, entry.name.length, true);
view.setUint32(42, entry.offset, true);
header.set(entry.name, 46);
centralParts.push(header);
offset += header.length;
}
const end = new Uint8Array(22);
const endView = new DataView(end.buffer);
endView.setUint32(0, 0x06054b50, true);
endView.setUint16(8, entries.length, true);
endView.setUint16(10, entries.length, true);
endView.setUint32(12, offset - centralOffset, true);
endView.setUint32(16, centralOffset, true);
return new Blob([...localParts, ...centralParts, end].map(arrayBufferCopy), { type: "application/zip" });
}
function arrayBufferCopy(value: Uint8Array) {
const copy = new Uint8Array(value.byteLength);
copy.set(value);
return copy.buffer;
}
function sanitizeFileName(value: string) {
return value.trim().replace(/[\\/:*?"<>|\u0000-\u001f]/g, "-").replace(/^\.+/, "").slice(0, 120);
}
function uniqueFileName(name: string, used: Set<string>) {
if (!used.has(name)) {
used.add(name);
return name;
}
const dot = name.lastIndexOf(".");
const base = dot > 0 ? name.slice(0, dot) : name;
const extension = dot > 0 ? name.slice(dot) : "";
let index = 2;
while (used.has(`${base}-${index}${extension}`)) index += 1;
const result = `${base}-${index}${extension}`;
used.add(result);
return result;
}
const crcTable = Array.from({ length: 256 }, (_, index) => {
let value = index;
for (let bit = 0; bit < 8; bit += 1) value = (value & 1) ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
return value >>> 0;
});
function crc32(data: Uint8Array) {
let crc = 0xffffffff;
for (const value of data) crc = crcTable[(crc ^ value) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
+135
View File
@@ -0,0 +1,135 @@
export type SeedreamCreationMode = "basic" | "interactive" | "layers";
export type SeedreamPoint = {
x: number;
y: number;
};
export type SeedreamBox = {
left: number;
top: number;
right: number;
bottom: number;
};
export type SeedreamAnnotation = {
id: string;
index: number;
kind: "point" | "box" | "stroke";
sourceKey?: string;
point?: SeedreamPoint;
box?: SeedreamBox;
points?: SeedreamPoint[];
};
export type SeedreamAnnotationReference = {
token: string;
label: string;
caption: "画布标注";
};
export function seedreamAnnotationToken(index: number, kind: SeedreamAnnotation["kind"] = "box") {
return kind === "stroke" ? `@标记${index}` : `@标注${index}`;
}
export function seedreamAnnotationReferences(annotations: SeedreamAnnotation[], query = ""): SeedreamAnnotationReference[] {
const normalizedQuery = query.replace(/^@+/, "").trim().toLowerCase();
return [...annotations]
.sort((a, b) => a.index - b.index)
.map((annotation) => {
const token = seedreamAnnotationToken(annotation.index, annotation.kind);
const label = annotation.kind === "box"
? `框选区域 ${annotation.index}`
: annotation.kind === "point"
? `点选位置 ${annotation.index}`
: `蓝色标记 ${annotation.index}`;
return { token, label, caption: "画布标注" as const };
})
.filter((reference) => !normalizedQuery || [reference.token, reference.token.slice(1), reference.label, reference.caption]
.some((value) => value.toLowerCase().includes(normalizedQuery)));
}
export function seedreamAnnotationTag(annotation: SeedreamAnnotation, imageIndex?: number) {
const prefix = imageIndex && imageIndex > 0 ? `图${imageIndex}` : "";
if (annotation.kind === "point" && annotation.point) {
return `${prefix}<point>${coordinate(annotation.point.x)} ${coordinate(annotation.point.y)}</point>`;
}
if (annotation.kind === "box" && annotation.box) {
return `${prefix}<bbox>${coordinate(annotation.box.left)} ${coordinate(annotation.box.top)} ${coordinate(annotation.box.right)} ${coordinate(annotation.box.bottom)}</bbox>`;
}
if (annotation.kind === "stroke" && annotation.points?.length) {
return `${prefix || "图片"}中的蓝色标记区域`;
}
return "";
}
export function missingSeedreamAnnotationTokens(prompt: string, annotations: SeedreamAnnotation[]) {
const available = new Set(annotations.map((annotation) => seedreamAnnotationToken(annotation.index, annotation.kind)));
const missing = new Set<string>();
for (const match of prompt.matchAll(/@(标注|标记)(\d+)/g)) {
const token = `@${match[1]}${match[2]}`;
if (!available.has(token)) missing.add(token);
}
return [...missing].sort((a, b) => annotationTokenIndex(a) - annotationTokenIndex(b));
}
export function buildSeedreamInteractivePrompt(prompt: string, annotations: SeedreamAnnotation[], sourceKeys: string[] = []) {
const annotationByIndex = new Map(annotations.map((annotation) => [annotation.index, annotation]));
const imageIndexBySource = new Map(sourceKeys.map((key, index) => [key, index + 1]));
const referenced = new Set<number>();
const transformed = normalizeSeedreamImageTokens(prompt).replace(/@(标注|标记)(\d+)/g, (token, kind: string, rawIndex: string) => {
const index = Number(rawIndex);
const annotation = annotationByIndex.get(index);
if (!annotation || seedreamAnnotationToken(index, annotation.kind) !== token) return token;
referenced.add(index);
return seedreamAnnotationTag(annotation, imageIndexFor(annotation, imageIndexBySource));
}).trim();
const unreferenced = annotations
.filter((annotation) => !referenced.has(annotation.index))
.map((annotation) => `${annotation.kind === "stroke" ? "标记" : "标注"}${annotation.index} ${seedreamAnnotationTag(annotation, imageIndexFor(annotation, imageIndexBySource))}`)
.filter((value) => !value.endsWith(" "));
if (!unreferenced.length) return transformed;
return `${transformed}\n编辑位置:${unreferenced.join(";")}。`.trim();
}
export function buildSeedreamLayerPrompt(prompt: string, annotations: SeedreamAnnotation[]) {
const annotationByIndex = new Map(annotations.map((annotation) => [annotation.index, annotation]));
const referenced = new Set<number>();
const transformed = normalizeSeedreamImageTokens(prompt, true).replace(/@(标注|标记)(\d+)/g, (token, _kind: string, rawIndex: string) => {
const index = Number(rawIndex);
const annotation = annotationByIndex.get(index);
if (!annotation || seedreamAnnotationToken(index, annotation.kind) !== token) return token;
referenced.add(index);
return seedreamAnnotationTag(annotation);
}).trim();
const unreferenced = annotations
.filter((annotation) => !referenced.has(annotation.index))
.map((annotation) => seedreamAnnotationTag(annotation))
.filter(Boolean);
if (!unreferenced.length) return transformed;
const instruction = `需拆分区域:${unreferenced.join(";")}。`;
return transformed ? `${transformed}\n${instruction}` : instruction;
}
export function normalizeSeedreamImageTokens(prompt: string, forceFirstImage = false) {
return prompt.replace(/@(图片|图)(\d+)/g, (_token, _kind: string, rawIndex: string) => `图${forceFirstImage ? 1 : Number(rawIndex)}`);
}
export function seedreamCreationModeLabel(mode: SeedreamCreationMode) {
if (mode === "interactive") return "交互编辑";
if (mode === "layers") return "图层拆分";
return "基础生图";
}
function coordinate(value: number) {
return Math.max(0, Math.min(1000, Math.round(value)));
}
function imageIndexFor(annotation: SeedreamAnnotation, imageIndexBySource: Map<string, number>) {
if (annotation.sourceKey) return imageIndexBySource.get(annotation.sourceKey) || 1;
return 1;
}
function annotationTokenIndex(token: string) {
return Number(token.match(/\d+$/)?.[0] || 0);
}