Commit remaining workspace changes
This commit is contained in:
@@ -20,7 +20,7 @@ export async function GET(
|
||||
|
||||
try {
|
||||
return NextResponse.json({
|
||||
item: createCanvasProjectExport({
|
||||
item: await createCanvasProjectExport({
|
||||
title: project.title,
|
||||
description: project.description,
|
||||
data: project.data,
|
||||
|
||||
@@ -7,7 +7,7 @@ export async function POST(request: Request) {
|
||||
const payload = await request.json().catch(() => null);
|
||||
|
||||
try {
|
||||
const project = importCanvasProjectExport(payload);
|
||||
const project = await importCanvasProjectExport(payload);
|
||||
return NextResponse.json({ item: project }, { status: 201 });
|
||||
} catch (err) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -13,7 +13,7 @@ export async function POST(request: Request) {
|
||||
|
||||
const buffer = Buffer.from(await image.arrayBuffer());
|
||||
const id = crypto.randomUUID();
|
||||
const persisted = persistUploadedImageFile(id, image.name || "canvas-upload.png", buffer);
|
||||
const persisted = await persistUploadedImageFile(id, image.name || "canvas-upload.png", buffer);
|
||||
|
||||
return NextResponse.json({
|
||||
item: {
|
||||
|
||||
@@ -609,29 +609,30 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
||||
) : history.length ? (
|
||||
<div className="space-y-2">
|
||||
{history.map((item) => (
|
||||
<button
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex w-full items-center gap-3 rounded-md border border-zinc-200 bg-white p-2 text-left hover:bg-zinc-50"
|
||||
onClick={() => selectHistoryItem(item)}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-md border border-zinc-200 bg-white p-2 hover:bg-zinc-50"
|
||||
>
|
||||
<img alt={item.prompt} className="size-14 rounded object-cover" src={item.imageUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{item.prompt}</div>
|
||||
<div className="text-[11px] text-zinc-500">{formatDate(item.createdAt)}</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
onClick={() => selectHistoryItem(item)}
|
||||
type="button"
|
||||
>
|
||||
<img alt={item.prompt} className="size-14 rounded object-cover" src={item.imageUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-medium">{item.prompt}</div>
|
||||
<div className="text-[11px] text-zinc-500">{formatDate(item.createdAt)}</div>
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void placeImageForEdit(item);
|
||||
}}
|
||||
onClick={() => void placeImageForEdit(item)}
|
||||
>
|
||||
<Brush />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -643,35 +644,36 @@ export function DirectStudio({ activeView }: { activeView: ActiveView }) {
|
||||
<div className="space-y-2">
|
||||
{history.length ? (
|
||||
history.map((item) => (
|
||||
<button
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex w-full items-center gap-3 rounded-md border border-zinc-200 bg-white p-2 text-left hover:bg-zinc-50"
|
||||
onClick={() =>
|
||||
setPreviewImage({
|
||||
title: item.prompt.slice(0, 24) || "作品",
|
||||
subtitle: formatDate(item.createdAt),
|
||||
url: item.imageUrl,
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
className="flex w-full items-center gap-3 rounded-md border border-zinc-200 bg-white p-2 hover:bg-zinc-50"
|
||||
>
|
||||
<img alt={item.prompt} className="size-16 rounded object-cover" src={item.imageUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{item.prompt}</div>
|
||||
<div className="text-xs text-zinc-500">{item.model} · {item.outputFormat}</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
onClick={() =>
|
||||
setPreviewImage({
|
||||
title: item.prompt.slice(0, 24) || "作品",
|
||||
subtitle: formatDate(item.createdAt),
|
||||
url: item.imageUrl,
|
||||
})
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<img alt={item.prompt} className="size-16 rounded object-cover" src={item.imageUrl} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{item.prompt}</div>
|
||||
<div className="text-xs text-zinc-500">{item.model} · {item.outputFormat}</div>
|
||||
</div>
|
||||
</button>
|
||||
<Button
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void placeImageForEdit(item);
|
||||
}}
|
||||
onClick={() => void placeImageForEdit(item)}
|
||||
>
|
||||
<Brush />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-sm text-zinc-500">暂无历史记录</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { extname, basename } from "node:path";
|
||||
import type { CanvasImageNodeMetadata, CanvasProjectData } from "@/lib/canvas/types";
|
||||
import { normalizeCanvasProjectData } from "@/lib/canvas/types";
|
||||
@@ -6,6 +5,7 @@ import { createCanvasProject } from "@/lib/server/repositories/canvas-project-re
|
||||
import {
|
||||
getGeneratedImagePathFromUrl,
|
||||
persistImageBuffer,
|
||||
readPersistedImageFile,
|
||||
} from "@/lib/server/storage/image-file-storage";
|
||||
|
||||
const CANVAS_EXPORT_KIND = "imagegen.canvas.project";
|
||||
@@ -29,11 +29,11 @@ export type CanvasProjectExportV1 = {
|
||||
assets: CanvasProjectExportAsset[];
|
||||
};
|
||||
|
||||
export function createCanvasProjectExport(input: {
|
||||
export async function createCanvasProjectExport(input: {
|
||||
title: string;
|
||||
description?: string;
|
||||
data: CanvasProjectData;
|
||||
}): CanvasProjectExportV1 {
|
||||
}): Promise<CanvasProjectExportV1> {
|
||||
return {
|
||||
kind: CANVAS_EXPORT_KIND,
|
||||
version: CANVAS_EXPORT_VERSION,
|
||||
@@ -41,11 +41,11 @@ export function createCanvasProjectExport(input: {
|
||||
title: input.title,
|
||||
description: input.description ?? "",
|
||||
data: input.data,
|
||||
assets: collectExportAssets(input.data),
|
||||
assets: await collectExportAssets(input.data),
|
||||
};
|
||||
}
|
||||
|
||||
export function importCanvasProjectExport(value: unknown) {
|
||||
export async function importCanvasProjectExport(value: unknown) {
|
||||
const payload = normalizeCanvasProjectExport(value);
|
||||
if (!payload) {
|
||||
throw new Error("导入 JSON 不是有效的画布导出格式");
|
||||
@@ -54,12 +54,12 @@ export function importCanvasProjectExport(value: unknown) {
|
||||
const assetsByNodeId = new Map(payload.assets.map((asset) => [asset.nodeId, asset]));
|
||||
const data: CanvasProjectData = {
|
||||
...payload.data,
|
||||
nodes: payload.data.nodes.map((node) => {
|
||||
nodes: await Promise.all(payload.data.nodes.map(async (node) => {
|
||||
if (node.type !== "image") return node;
|
||||
const asset = assetsByNodeId.get(node.id);
|
||||
if (!asset) return node;
|
||||
|
||||
const persisted = persistDataUrl(asset.dataUrl, asset.fileName);
|
||||
const persisted = await persistDataUrl(asset.dataUrl, asset.fileName);
|
||||
return {
|
||||
...node,
|
||||
metadata: {
|
||||
@@ -68,7 +68,7 @@ export function importCanvasProjectExport(value: unknown) {
|
||||
filePath: persisted.filePath,
|
||||
} satisfies CanvasImageNodeMetadata,
|
||||
};
|
||||
}),
|
||||
})),
|
||||
};
|
||||
|
||||
const title = payload.title.trim()
|
||||
@@ -82,7 +82,7 @@ export function importCanvasProjectExport(value: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
function collectExportAssets(data: CanvasProjectData) {
|
||||
async function collectExportAssets(data: CanvasProjectData) {
|
||||
const assets: CanvasProjectExportAsset[] = [];
|
||||
|
||||
for (const node of data.nodes) {
|
||||
@@ -91,11 +91,15 @@ function collectExportAssets(data: CanvasProjectData) {
|
||||
if (!metadata.imageUrl) continue;
|
||||
|
||||
const filePath = metadata.filePath ?? getGeneratedImagePathFromUrl(metadata.imageUrl);
|
||||
if (!filePath || !existsSync(filePath)) {
|
||||
if (!filePath) {
|
||||
throw new Error(`图片文件不存在:${node.title || node.id}`);
|
||||
}
|
||||
|
||||
const buffer = await readPersistedImageFile(filePath);
|
||||
if (!buffer) {
|
||||
throw new Error(`图片文件不存在:${node.title || node.id}`);
|
||||
}
|
||||
|
||||
const buffer = readFileSync(filePath);
|
||||
const mimeType = getMimeType(filePath);
|
||||
assets.push({
|
||||
nodeId: node.id,
|
||||
|
||||
@@ -455,7 +455,7 @@ async function runImageJob(
|
||||
});
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const persisted = persistBase64Image(id, input.outputFormat, b64);
|
||||
const persisted = await persistBase64Image(id, input.outputFormat, b64);
|
||||
const createdAt = new Date().toISOString();
|
||||
const resultPayload = {
|
||||
id,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
const imageDir = join(
|
||||
/* turbopackIgnore: true */ process.cwd(),
|
||||
@@ -13,6 +15,23 @@ export type PersistedImageFile = {
|
||||
imageUrl: string;
|
||||
};
|
||||
|
||||
type S3StorageConfig = {
|
||||
endpoint: string;
|
||||
accessKeyId: string;
|
||||
secretAccessKey: string;
|
||||
bucket: string;
|
||||
region: string;
|
||||
publicUrl?: string;
|
||||
};
|
||||
|
||||
const s3ObjectPrefix = "generated";
|
||||
const defaultBucket = "imagegen-tools";
|
||||
const defaultRegion = "us-east-1";
|
||||
|
||||
const globalForS3Storage = globalThis as typeof globalThis & {
|
||||
imageStorageS3Client?: S3Client;
|
||||
};
|
||||
|
||||
export function getImageDir() {
|
||||
if (!existsSync(imageDir)) {
|
||||
mkdirSync(imageDir, { recursive: true });
|
||||
@@ -21,27 +40,54 @@ export function getImageDir() {
|
||||
return imageDir;
|
||||
}
|
||||
|
||||
export function persistBase64Image(
|
||||
export async function persistBase64Image(
|
||||
id: string,
|
||||
extension: string,
|
||||
base64: string,
|
||||
): PersistedImageFile {
|
||||
): Promise<PersistedImageFile> {
|
||||
const normalizedExtension = extension === "jpeg" ? "jpg" : extension;
|
||||
const fileName = `${id}.${normalizedExtension}`;
|
||||
const filePath = join(getImageDir(), fileName);
|
||||
const imageUrl = `/generated/${fileName}`;
|
||||
writeFileSync(filePath, Buffer.from(base64, "base64"));
|
||||
|
||||
return { fileName, filePath, imageUrl };
|
||||
return persistImageBufferWithFileName(fileName, Buffer.from(base64, "base64"));
|
||||
}
|
||||
|
||||
export function persistUploadedImageFile(
|
||||
export async function persistUploadedImageFile(
|
||||
id: string,
|
||||
filename: string,
|
||||
buffer: Buffer,
|
||||
): PersistedImageFile {
|
||||
): Promise<PersistedImageFile> {
|
||||
const extension = getExtensionFromFilename(filename);
|
||||
const fileName = `${id}.${extension}`;
|
||||
return persistImageBufferWithFileName(fileName, buffer);
|
||||
}
|
||||
|
||||
export async function persistImageBuffer(
|
||||
id: string,
|
||||
extension: string,
|
||||
buffer: Buffer,
|
||||
): Promise<PersistedImageFile> {
|
||||
const normalizedExtension = normalizeExtension(extension);
|
||||
const fileName = `${id}.${normalizedExtension}`;
|
||||
return persistImageBufferWithFileName(fileName, buffer);
|
||||
}
|
||||
|
||||
export async function readPersistedImageFile(filePath: string): Promise<Buffer | null> {
|
||||
if (filePath.startsWith("s3://")) {
|
||||
return readS3Object(filePath);
|
||||
}
|
||||
|
||||
const { readFileSync } = await import("node:fs");
|
||||
if (!existsSync(filePath)) return null;
|
||||
return readFileSync(filePath);
|
||||
}
|
||||
|
||||
export function isLocalImageFile(filePath: string) {
|
||||
return !filePath.startsWith("s3://");
|
||||
}
|
||||
|
||||
function persistImageBufferLocally(
|
||||
fileName: string,
|
||||
buffer: Buffer,
|
||||
): PersistedImageFile {
|
||||
const filePath = join(getImageDir(), fileName);
|
||||
const imageUrl = `/generated/${fileName}`;
|
||||
writeFileSync(filePath, buffer);
|
||||
@@ -49,18 +95,33 @@ export function persistUploadedImageFile(
|
||||
return { fileName, filePath, imageUrl };
|
||||
}
|
||||
|
||||
export function persistImageBuffer(
|
||||
id: string,
|
||||
extension: string,
|
||||
async function persistImageBufferWithFileName(
|
||||
fileName: string,
|
||||
buffer: Buffer,
|
||||
): PersistedImageFile {
|
||||
const normalizedExtension = normalizeExtension(extension);
|
||||
const fileName = `${id}.${normalizedExtension}`;
|
||||
const filePath = join(getImageDir(), fileName);
|
||||
const imageUrl = `/generated/${fileName}`;
|
||||
writeFileSync(filePath, buffer);
|
||||
): Promise<PersistedImageFile> {
|
||||
const config = getS3StorageConfig();
|
||||
if (!config) {
|
||||
return persistImageBufferLocally(fileName, buffer);
|
||||
}
|
||||
|
||||
return { fileName, filePath, imageUrl };
|
||||
const key = `${s3ObjectPrefix}/${fileName}`;
|
||||
const contentType = getMimeTypeFromFileName(fileName);
|
||||
const client = getS3Client(config);
|
||||
|
||||
await client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: config.bucket,
|
||||
Key: key,
|
||||
Body: buffer,
|
||||
ContentType: contentType,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
fileName,
|
||||
filePath: `s3://${config.bucket}/${key}`,
|
||||
imageUrl: createPublicImageUrl(config, key),
|
||||
};
|
||||
}
|
||||
|
||||
export function getGeneratedImagePathFromUrl(imageUrl: string) {
|
||||
@@ -68,6 +129,104 @@ export function getGeneratedImagePathFromUrl(imageUrl: string) {
|
||||
return join(getImageDir(), basename(imageUrl));
|
||||
}
|
||||
|
||||
function getS3StorageConfig(): S3StorageConfig | null {
|
||||
const endpoint = process.env.FILE_STORAGE_API?.trim();
|
||||
const accessKeyId = process.env.FILE_STORAGE_ACCESS_KEY?.trim();
|
||||
const secretAccessKey = process.env.FILE_STORAGE_SECRET_KEY?.trim();
|
||||
const bucket = process.env.FILE_STORAGE_BUCKET?.trim() || defaultBucket;
|
||||
|
||||
// ✅ 添加调试日志
|
||||
console.log('S3 Config Check:', {
|
||||
hasEndpoint: !!endpoint,
|
||||
hasAccessKey: !!accessKeyId,
|
||||
hasSecretKey: !!secretAccessKey,
|
||||
bucket,
|
||||
endpoint: endpoint ? endpoint.replace(/\/+$/, "") : 'not set',
|
||||
});
|
||||
|
||||
if (!endpoint || !accessKeyId || !secretAccessKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: endpoint.replace(/\/+$/, ""),
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
bucket: process.env.FILE_STORAGE_BUCKET?.trim() || defaultBucket,
|
||||
region: process.env.FILE_STORAGE_REGION?.trim() || defaultRegion,
|
||||
publicUrl: process.env.FILE_STORAGE_PUBLIC_URL?.trim().replace(/\/+$/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
function getS3Client(config: S3StorageConfig) {
|
||||
if (!globalForS3Storage.imageStorageS3Client) {
|
||||
globalForS3Storage.imageStorageS3Client = new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
}
|
||||
|
||||
return globalForS3Storage.imageStorageS3Client;
|
||||
}
|
||||
|
||||
async function readS3Object(filePath: string) {
|
||||
const config = getS3StorageConfig();
|
||||
if (!config) return null;
|
||||
|
||||
const object = parseS3FilePath(filePath);
|
||||
if (!object) return null;
|
||||
|
||||
const response = await getS3Client(config).send(
|
||||
new GetObjectCommand({
|
||||
Bucket: object.bucket,
|
||||
Key: object.key,
|
||||
}),
|
||||
);
|
||||
|
||||
if (!response.Body) return null;
|
||||
if (response.Body instanceof Readable) {
|
||||
return streamToBuffer(response.Body);
|
||||
}
|
||||
|
||||
const body = response.Body as { transformToByteArray?: () => Promise<Uint8Array> };
|
||||
if (body.transformToByteArray) {
|
||||
return Buffer.from(await body.transformToByteArray());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseS3FilePath(filePath: string) {
|
||||
const match = /^s3:\/\/([^/]+)\/(.+)$/.exec(filePath);
|
||||
if (!match) return null;
|
||||
return {
|
||||
bucket: match[1],
|
||||
key: match[2],
|
||||
};
|
||||
}
|
||||
|
||||
async function streamToBuffer(stream: Readable) {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
function createPublicImageUrl(config: S3StorageConfig, key: string) {
|
||||
const encodedKey = key.split("/").map(encodeURIComponent).join("/");
|
||||
if (config.publicUrl) {
|
||||
return `${config.publicUrl}/${encodedKey}`;
|
||||
}
|
||||
|
||||
return `${config.endpoint}/${config.bucket}/${encodedKey}`;
|
||||
}
|
||||
|
||||
function getExtensionFromFilename(filename: string) {
|
||||
const parts = filename.split(".");
|
||||
const extension = parts.at(-1)?.toLowerCase();
|
||||
@@ -78,3 +237,10 @@ function normalizeExtension(extension: string | undefined) {
|
||||
if (!extension || extension.length > 5) return "png";
|
||||
return extension === "jpeg" ? "jpg" : extension;
|
||||
}
|
||||
|
||||
function getMimeTypeFromFileName(fileName: string) {
|
||||
const extension = getExtensionFromFilename(fileName);
|
||||
if (extension === "jpg" || extension === "jpeg") return "image/jpeg";
|
||||
if (extension === "webp") return "image/webp";
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user