Commit remaining workspace changes

This commit is contained in:
2026-06-23 12:23:40 +08:00
parent 0ec26843d2
commit a4a10df9e3
28 changed files with 669 additions and 68 deletions
@@ -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,
+1 -1
View File
@@ -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(
+1 -1
View File
@@ -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: {
+36 -34
View File
@@ -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,
+1 -1
View File
@@ -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,
+185 -19
View File
@@ -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";
}