Compare commits

...

12 Commits

Author SHA1 Message Date
HouYunFei 50a3a94876 chore(version): 更新版本号从 v0.1.0 到 v0.1.1
- 将 VERSION 文件中的版本号从 v0.1.0 更新为 v0.1.1
2026-05-30 13:09:01 +08:00
HouYunFei 6867f3e1e6 feat(config): 支持通过URL参数自动配置API设置
- 新增通过URL查询参数baseUrl/baseurl和apiKey/apikey自动填充配置的功能
- 实现读取参数后从地址栏自动移除相关参数的安全机制
- 添加后台是否允许自定义渠道的检查逻辑
- 集成Ant Design的消息提示和对话框组件用于用户反馈
- 更新文档说明New API自动配置的使用方法和参数格式
- 优化配置初始化流程以处理外部软件跳转场景
2026-05-30 13:08:43 +08:00
HouYunFei e740da6702 chore(Dockerfile): 将基础镜像从 Bun 切换到 Node.js 并更新启动命令 2026-05-28 10:36:27 +08:00
HouYunFei fcde508482 chore(version): 更新版本号并完善变更日志
- 更新版本号从 v0.0.9 到 v0.1.0
- 在变更日志中添加 v0.1.0 版本记录
- 记录优化我的画布、我的素材导出功能
- 记录修复画布撤销、配置节点等 bug 问题
2026-05-26 16:05:40 +08:00
HouYunFei 7e5179c54f fix(canvas): 解决画布节点撤销操作后图片丢失问题
- 添加 cleanupCanvasFiles 回调函数用于清理画布资源
- 将 cleanupAssetImages 替换为 cleanupCanvasFiles 以统一资源清理逻辑
- 在节点变更和画布清空时使用新的清理函数
- 修复撤销操作后节点信息恢复但本地图片数据被清理的错误
2026-05-26 15:50:36 +08:00
HouYunFei fda8efb362 fix(canvas): 修复画布视频设置浮层遮挡和交互问题
- 将视频设置浮层改为挂载到页面根层级并使用自建浮层交互
- 避免被节点悬浮工具栏遮挡或点击面板内容时意外关闭
- 在画布配置节点组件中为视频设置浮层添加 placement 属性
- 更新文档描述以反映浮层交互方式的改进
2026-05-26 15:28:35 +08:00
HouYunFei 7ac1fea662 refactor(canvas): 重构画布图像设置弹窗组件
- 使用 Portal 重新实现弹窗渲染机制
- 移除 Ant Design Popover 组件依赖
- 添加手动定位逻辑替代自动溢出调整
- 优化外部点击关闭事件处理机制
- 修复弹窗位置计算和滚动同步问题
- 简化组件参数并改进 DOM 结构
2026-05-26 15:17:29 +08:00
HouYunFei 005c396660 feat(image): 优化图片生成记录持久化和导出功能
- 新增生成记录配置持久化功能,保存提示词、参考图、模型、质量、尺寸和张数配置
- 结果图写入本地图片存储后只保存 storageKey,减少存储空间占用
- 点击历史记录可回填生成配置并预览结果
- 优化我的画布、我的素材导出功能
- 新增视频创作台页面
- 修复图片节点size参数传递问题
- 添加完整的生成日志数据结构和序列化处理
- 实现图片存储和删除的批量操作支持
2026-05-26 14:44:06 +08:00
HouYunFei bf66a25936 feat(image): 优化图片生成记录持久化和导出功能
- 新增生成记录配置持久化功能,保存提示词、参考图、模型、质量、尺寸和张数配置
- 结果图写入本地图片存储后只保存 storageKey,减少存储空间占用
- 点击历史记录可回填生成配置并预览结果
- 优化我的画布、我的素材导出功能
- 新增视频创作台页面
- 修复图片节点size参数传递问题
- 添加完整的生成日志数据结构和序列化处理
- 实现图片存储和删除的批量操作支持
2026-05-26 14:09:10 +08:00
HouYunFei de25ef515e feat(assets): 使用 file-saver 库优化文件下载功能
- 引入 file-saver 库替代手动创建下载链接的方式
- 在 asset-transfer.ts 中使用 saveAs 替代 URL.createObjectURL 和手动点击链接
- 在 canvas-export.ts 中使用 saveAs 替代原有的文件下载逻辑
- 更新 package.json 添加 file-saver 依赖
- 更新 bun.lock 文件同步依赖变更
2026-05-26 13:21:52 +08:00
HouYunFei 9311b8a92b feat(asset): 添加素材导入导出功能
- 实现了素材批量导出为包含 assets.json 和媒体文件的压缩包
- 实现了从压缩包导入素材到本地存储的功能
- 在素材页面添加了导出素材和导入素材的按钮入口
- 创建了 asset-transfer 模块处理素材打包和解包逻辑
- 支持图片和视频文件的存储路径映射和恢复
- 添加了文件安全命名和 MIME 类型识别功能
2026-05-26 13:13:05 +08:00
HouYunFei 1fb782819b feat(canvas): 实现画布项目导出为ZIP压缩包功能
- 新增 canvas-export 工具模块,支持将画布项目打包为 ZIP 文件
- 修改导出功能,将原来的 JSON 格式改为 ZIP 压缩包,包含 projects.json 和相关媒体文件
- 添加对 fflate 库的依赖用于 ZIP 文件处理
- 更新画布库页面,支持多选项目批量导出
- 修改导入功能以支持读取新版 ZIP 格式的画布项目
- 新增 zip 处理工具库,封装压缩和解压操作
- 扩展文件存储服务,添加获取和设置 Blob 对象的方法
- 更新 AGENTS.md 文档,添加标准库使用原则
2026-05-26 13:00:16 +08:00
25 changed files with 745 additions and 159 deletions
+1
View File
@@ -6,6 +6,7 @@
- 先读现有代码,再动手修改,优先沿用项目已有结构和写法。
- 写代码保持最少行数,能简单实现就不要引入复杂抽象。
- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。
- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。
- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容、数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。
- 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。
+11
View File
@@ -2,6 +2,17 @@
## Unreleased
+ [新增] 支持New API跳转并自动填入Base URL和API Key配置。
## v0.1.0 - 2026-05-30
## v0.1.0 - 2026-05-26
+ [优化] 优化我的画布、我的素材导出功能
+ [修复] 修复画布撤销,配置节点等bug问题
## v0.0.9 - 2026-05-26
+ [新增] 新增视频创作台页面。
+ [修复] 修复图片节点size参数传递问题。
+2 -2
View File
@@ -25,7 +25,7 @@ COPY main.go ./
RUN go build -o /server .
# 运行镜像:Next.js 对外监听 3000,Go 只在容器内部监听 8080。
FROM oven/bun:1.3.13
FROM node:22-bookworm-slim
WORKDIR /app
COPY VERSION /app/VERSION
@@ -38,4 +38,4 @@ RUN mkdir -p /app/data/prompts
EXPOSE 3000
# 先启动内部 Go API,再由 Next.js 提供页面并代理 /api/*。
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && HOSTNAME=0.0.0.0 PORT=3000 bun run start"]
CMD ["sh", "-c", "PORT=8080 /app/server & cd /app/web && HOSTNAME=0.0.0.0 PORT=3000 npm run start"]
+11 -1
View File
@@ -51,6 +51,17 @@ docker compose -f docker-compose.local.yml up -d --build
如需要拉取提示词,可前往:`http://localhost:3000/admin/prompts`
## New API 自动配置
如果使用 New API,可在 `系统设置 -> 聊天方式 -> 添加聊天设置` 中填入:
```text
https://infinite-canvas-cpco.onrender.com?apiKey={key}&baseUrl={address}
```
跳转后会自动打开配置弹窗并填入 API Key 和 Base URL。
如果自己部署了,可以把 `https://infinite-canvas-cpco.onrender.com` 替换成你部署的地址。
## 效果展示
<table width="100%">
@@ -89,7 +100,6 @@ docker compose -f docker-compose.local.yml up -d --build
本项目使用 GNU Affero General Public License v3.0,见 [LICENSE](LICENSE)。
## Star History
<a href="https://www.star-history.com/?repos=basketikun%2Finfinite-canvas&type=date&legend=top-left">
+1 -1
View File
@@ -1 +1 @@
v0.0.9
v0.1.1
+10 -2
View File
@@ -1,7 +1,14 @@
# 待测试
- 外部软件可通过 URL 查询参数 `baseUrl`/`baseurl``apiKey`/`apikey` 跳转到前端;读取后会从地址栏移除这些参数,后台允许自定义渠道时会自动切到自定义渠道、填入配置并打开配置弹窗,未允许时会打开配置弹窗并提示无法导入。
- 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。
- 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。
- 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。
- 修复删除画布图片节点或清空画布后撤销时,节点信息恢复但本地图片数据已被清理导致图片丢失的问题。
- “我的素材”类型筛选区右侧新增文本样式的导出素材和导入素材入口,可将全部素材导出为包含 `assets.json` 与图片、视频文件的压缩包,并从压缩包恢复素材。
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
- 生图工作台新增生成记录配置持久化:每次生成会保存提示词、参考图、模型、质量、尺寸和张数,结果图写入本地图片存储后记录只保存 `storageKey`;点击历史记录会回填本次生成配置并预览结果。
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`
- 修复画布和生图工作台选择图片尺寸后,请求图片生成/编辑接口未携带 `size` 参数的问题;`auto` 不传,其余比例或像素尺寸会随请求发送。
- 修复生图工作台和画布生图请求中 `quality` 参数可能传入上游不支持值导致 400 的问题;请求前会归一化质量枚举,`auto` 或异常值不再发送给上游。
@@ -9,8 +16,9 @@
- 管理后台编辑渠道时,API Key 留空不再触发必填校验,表示沿用已保存的密钥;新增渠道仍要求填写 API Key。
- 管理后台公开配置里的系统可用模型候选项改为由已启用渠道中选择的模型合并去重生成,最终开放哪些模型仍由公开配置里手动勾选。
- 视频生成请求参数对齐 `grok-imagine-video` 接口:使用 `resolution_name``preset=normal``input_reference[]`,支持清晰度、尺寸、秒数快捷选择和手动输入,并支持最多 7 张参考图。
- 画布视频设置浮层改为挂载到页面根层级,避免被节点悬浮工具栏遮挡。
- 画布视频设置浮层改为挂载到页面根层级并使用自建浮层交互,避免被节点悬浮工具栏遮挡或点击面板内容时关闭
- 画布生成配置节点的生图参数改为复用图像设置浮层,支持在同一个入口里调整质量、尺寸和生成张数。
- 视频清晰度输入框改为只输入数字,提交请求时再拼接 `p` 单位。
- 视频生成前端会识别后端 `{ code, msg }` 错误响应,创建失败不再继续轮询 `undefined`
- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入。
- 新增 `/video` 视频创作台页面,参考生图工作台布局,支持提示词、参考图、视频参数、生成结果、保存素材、下载和本地生成记录;清晰度和秒数均支持常用值选择与手动输入,生成记录只保存媒体 `storageKey` 并可回填本次提示词、参考图和参数
- 视频创作台生成前会把模型、尺寸、秒数、清晰度归一化为视频接口支持的参数,并展示后端返回的错误信息,避免页面侧残留的生图参数影响视频请求。
+9
View File
@@ -17,6 +17,8 @@
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"dayjs": "^1.11.20",
"fflate": "^0.8.3",
"file-saver": "^2.0.5",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
@@ -33,6 +35,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/file-saver": "^2.0.7",
"@types/node": "^20",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
@@ -554,6 +557,8 @@
"@types/debug": ["@types/debug@4.1.13", "https://registry.npmmirror.com/@types/debug/-/debug-4.1.13.tgz", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/file-saver": ["@types/file-saver@2.0.7", "", {}, "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A=="],
"@types/hast": ["@types/hast@2.3.10", "https://registry.npmmirror.com/@types/hast/-/hast-2.3.10.tgz", { "dependencies": { "@types/unist": "^2" } }, "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw=="],
"@types/mdast": ["@types/mdast@3.0.15", "https://registry.npmmirror.com/@types/mdast/-/mdast-3.0.15.tgz", { "dependencies": { "@types/unist": "^2" } }, "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ=="],
@@ -802,8 +807,12 @@
"fetch-blob": ["fetch-blob@3.2.0", "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"figures": ["figures@6.1.0", "https://registry.npmmirror.com/figures/-/figures-6.1.0.tgz", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"file-saver": ["file-saver@2.0.5", "", {}, "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="],
"fill-range": ["fill-range@7.1.1", "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"finalhandler": ["finalhandler@2.1.1", "https://registry.npmmirror.com/finalhandler/-/finalhandler-2.1.1.tgz", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
+3
View File
@@ -23,6 +23,8 @@
"clsx": "^2.1.1",
"copy-to-clipboard": "^4.0.2",
"dayjs": "^1.11.20",
"fflate": "^0.8.3",
"file-saver": "^2.0.5",
"localforage": "^1.10.0",
"lucide-react": "^1.16.0",
"motion": "^12.38.0",
@@ -39,6 +41,7 @@
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/file-saver": "^2.0.7",
"@types/node": "^20",
"@types/react": "19.1.12",
"@types/react-dom": "19.1.9",
@@ -0,0 +1,72 @@
import { saveAs } from "file-saver";
import { createZip, readZip } from "@/lib/zip";
import { getMediaBlob, setMediaBlob } from "@/services/file-storage";
import { getImageBlob, setImageBlob } from "@/services/image-storage";
import type { Asset } from "@/stores/use-asset-store";
type AssetExportFile = {
app: "infinite-canvas";
version: 1;
exportedAt: string;
assets: Asset[];
files: AssetExportItem[];
};
type AssetExportItem = {
storageKey: string;
path: string;
mimeType: string;
bytes: number;
};
export async function exportAssets(assets: Asset[]) {
const files: AssetExportItem[] = [];
const zipFiles: { name: string; data: BlobPart }[] = [];
await Promise.all(
assets.map(async (asset) => {
const storageKey = asset.kind === "image" || asset.kind === "video" ? asset.data.storageKey : undefined;
if (!storageKey) return;
const blob = asset.kind === "image" ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) return;
const path = `files/${safeFileName(storageKey)}.${fileExtension(blob.type, asset.kind)}`;
files.push({ storageKey, path, mimeType: blob.type || asset.data.mimeType, bytes: blob.size });
zipFiles.push({ name: path, data: blob });
}),
);
const data: AssetExportFile = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), assets, files };
const zip = await createZip([{ name: "assets.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
saveAs(zip, "我的素材.zip");
}
export async function readAssetPackage(file: File) {
const zip = await readZip(file);
const assetFile = zip.get("assets.json");
if (!assetFile) throw new Error("missing assets.json");
const data = JSON.parse(await assetFile.text()) as AssetExportFile;
await Promise.all(
data.files.map(async (item) => {
const blob = zip.get(item.path);
if (!blob) return;
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, item.mimeType);
await (item.storageKey.startsWith("image:") ? setImageBlob(item.storageKey, typedBlob) : setMediaBlob(item.storageKey, typedBlob));
}),
);
return data.assets;
}
function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, "_");
}
function fileExtension(mimeType: string, kind: Asset["kind"]) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("jpeg")) return "jpg";
if (mimeType.includes("webp")) return "webp";
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
return kind === "image" ? "png" : "bin";
}
+56 -11
View File
@@ -3,12 +3,14 @@
import { Copy, Download, PencilLine, Search, Trash2, Upload } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { App, Button, Card, Drawer, Empty, Form, Image, Input, Modal, Pagination, Select, Space, Tag, Typography } from "antd";
import { saveAs } from "file-saver";
import { useCopyText } from "@/hooks/use-copy-text";
import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils";
import { uploadImage } from "@/services/image-storage";
import { cn } from "@/lib/utils";
import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
import { exportAssets, readAssetPackage } from "./asset-transfer";
type AssetFormValues = {
kind: AssetKind;
@@ -35,6 +37,7 @@ export default function AssetsPage() {
const [form] = Form.useForm<AssetFormValues>();
const coverInputRef = useRef<HTMLInputElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const assetInputRef = useRef<HTMLInputElement>(null);
const assets = useAssetStore((state) => state.assets);
const addAsset = useAssetStore((state) => state.addAsset);
const updateAsset = useAssetStore((state) => state.updateAsset);
@@ -147,10 +150,34 @@ export default function AssetsPage() {
const downloadImage = (asset: Asset) => {
if (asset.kind !== "image" && asset.kind !== "video") return;
const link = document.createElement("a");
link.href = asset.kind === "video" ? asset.data.url : asset.data.dataUrl;
link.download = `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`;
link.click();
saveAs(asset.kind === "video" ? asset.data.url : asset.data.dataUrl, `${asset.title || "asset"}.${asset.data.mimeType.split("/")[1] || "png"}`);
};
const exportAllAssets = async () => {
if (!validAssets.length) {
message.warning("暂无素材可导出");
return;
}
await exportAssets(validAssets);
};
const importAssetZip = async (file?: File) => {
if (!file) return;
try {
const importedAssets = await readAssetPackage(file);
importedAssets.forEach((asset) => {
const payload = { ...asset } as Record<string, unknown>;
delete payload.id;
delete payload.createdAt;
delete payload.updatedAt;
addAsset(payload as Parameters<typeof addAsset>[0]);
});
message.success(`已导入 ${importedAssets.length} 个素材`);
} catch {
message.error("导入失败,请选择有效的素材压缩包");
} finally {
if (assetInputRef.current) assetInputRef.current.value = "";
}
};
const confirmDelete = () => {
@@ -208,13 +235,29 @@ export default function AssetsPage() {
))}
</div>
</div>
<button
type="button"
className="cursor-pointer self-start text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline sm:self-center dark:text-stone-300"
onClick={openCreate}
>
</button>
<div className="flex flex-wrap gap-4">
<button
type="button"
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={() => void exportAllAssets()}
>
</button>
<button
type="button"
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={() => assetInputRef.current?.click()}
>
</button>
<button
type="button"
className="cursor-pointer text-sm font-medium text-stone-700 underline-offset-4 hover:underline focus-visible:outline-none focus-visible:underline dark:text-stone-300"
onClick={openCreate}
>
</button>
</div>
</div>
</div>
</div>
@@ -352,6 +395,8 @@ export default function AssetsPage() {
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
{deletingAsset?.title}
</Modal>
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } fr
import type { ChangeEvent as ReactChangeEvent, DragEvent as ReactDragEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
import { useParams, useRouter } from "next/navigation";
import { Home, ImageIcon, Images, List, Menu, MessageSquare, Plus, Redo2, Settings2, Trash2, Undo2, Upload, Video } from "lucide-react";
import { saveAs } from "file-saver";
import { requestEdit, requestGeneration, requestImageQuestion } from "@/services/api/image";
import { requestVideoGeneration } from "@/services/api/video";
@@ -291,6 +292,13 @@ function InfiniteCanvasPage() {
[activeChatId, backgroundMode, chatSessions, showImageInfo],
);
const cleanupCanvasFiles = useCallback(
(extra?: unknown) => {
cleanupAssetImages({ extra, history: historyRef.current, lastHistory: lastHistoryRef.current });
},
[cleanupAssetImages],
);
useEffect(() => {
if (!hydrated) return;
setProjectLoaded(false);
@@ -648,9 +656,9 @@ function InfiniteCanvasPage() {
setPreviewNodeId((current) => (current && allIds.has(current) ? null : current));
setRunningNodeId((current) => (current && allIds.has(current) ? null : current));
setContextMenu((current) => (current && allIds.has(current.nodeId) ? null : current));
cleanupAssetImages({ projectId, nodes: nodesRef.current.filter((node) => !allIds.has(node.id)), chatSessions });
cleanupCanvasFiles({ projectId, nodes: nodesRef.current.filter((node) => !allIds.has(node.id)), chatSessions });
},
[chatSessions, cleanupAssetImages, projectId],
[chatSessions, cleanupCanvasFiles, projectId],
);
const deselectCanvas = useCallback(() => {
@@ -675,8 +683,8 @@ function InfiniteCanvasPage() {
setRunningNodeId(null);
deselectCanvas();
setClearConfirmOpen(false);
cleanupAssetImages({ projectId, nodes: [], chatSessions: [] });
}, [cleanupAssetImages, deselectCanvas, projectId]);
cleanupCanvasFiles({ projectId, nodes: [], chatSessions: [] });
}, [cleanupCanvasFiles, deselectCanvas, projectId]);
const duplicateNode = useCallback((nodeId: string) => {
const source = nodesRef.current.find((node) => node.id === nodeId);
@@ -1317,10 +1325,7 @@ function InfiniteCanvasPage() {
const downloadNodeImage = useCallback((node: CanvasNodeData) => {
if ((node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) || !node.metadata?.content) return;
const link = document.createElement("a");
link.href = node.metadata.content;
link.download = `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : imageExtension(node.metadata.content)}`;
link.click();
saveAs(node.metadata.content, `canvas-${node.type}-${node.id}.${node.type === CanvasNodeType.Video ? "mp4" : imageExtension(node.metadata.content)}`);
}, []);
const saveNodeAsset = useCallback(
@@ -3,14 +3,14 @@
import type { CSSProperties } from "react";
import { useState } from "react";
import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, Edit3, Eye, Image as ImageIcon, LoaderCircle, MessageSquare, Play, Video } from "lucide-react";
import { App, Button, Empty, Input, InputNumber, Modal, Segmented } from "antd";
import { App, Button, Empty, Input, Modal, Segmented } from "antd";
import { ModelPicker } from "@/components/model-picker";
import { defaultConfig, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { canvasThemes } from "@/lib/canvas-theme";
import { useThemeStore } from "@/stores/use-theme-store";
import { CanvasSizePicker } from "./canvas-size-picker";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
import type { NodeGenerationInput } from "./canvas-node-generation";
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata } from "../types";
@@ -118,10 +118,13 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
</button>
</div>
<div className="mb-2 grid min-w-0 cursor-default grid-cols-[minmax(0,1fr)_92px_64px] items-center gap-2" onMouseDown={(event) => event.stopPropagation()}>
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "text" ? "grid-cols-1" : "grid-cols-[minmax(0,1fr)_148px]"}`} onMouseDown={(event) => event.stopPropagation()}>
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
{mode === "video" ? <CanvasVideoSettingsPopover config={config} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "videoSeconds" ? { seconds: value } : { [key]: value })} /> : mode === "image" ? <CanvasSizePicker className="h-10 min-w-0" value={node.metadata?.size || globalConfig.size || defaultConfig.size} onChange={(value) => onConfigChange(node.id, { size: value })} /> : null}
{mode === "video" ? null : <InputNumber min={1} max={15} className="canvas-compact-control canvas-control-number h-10 !w-full" value={count} onChange={(value) => onConfigChange(node.id, { count: Number(value) || 1 })} />}
{mode === "video" ? (
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "videoSeconds" ? { seconds: value } : { [key]: value })} />
) : mode === "image" ? (
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
) : null}
</div>
<Button
@@ -317,7 +320,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
return {
...globalConfig,
model: node.metadata?.model || defaultModel || globalConfig.model || defaultConfig.model,
quality: globalConfig.quality || defaultConfig.quality,
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds,
vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality,
@@ -1,7 +1,9 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button, Popover } from "antd";
import { Button } from "antd";
import { ImageSettingsPanel, imageQualityLabel, imageSizeLabel } from "@/components/image-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -16,30 +18,108 @@ type CanvasImageSettingsPopoverProps = {
buttonClassName?: string;
getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement;
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
autoAdjustOverflow?: boolean;
};
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, getPopupContainer, placement = "topLeft" }: CanvasImageSettingsPopoverProps) {
export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, placement = "topLeft" }: CanvasImageSettingsPopoverProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
const quality = config.quality || "auto";
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
const activeSize = config.size || "auto";
const updateOpen = (nextOpen: boolean) => {
setOpen(nextOpen);
onOpenChange?.(nextOpen);
};
useEffect(() => {
if (!open) return;
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
setOpen(false);
onOpenChange?.(false);
};
syncPosition();
window.addEventListener("resize", syncPosition);
window.addEventListener("scroll", syncPosition, true);
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => {
window.removeEventListener("resize", syncPosition);
window.removeEventListener("scroll", syncPosition, true);
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
};
}, [onOpenChange, open]);
const panel = open && buttonRect ? <ImageSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
return (
<Popover
trigger="click"
placement={placement}
arrow={false}
overlayClassName="canvas-image-settings-popover"
color={theme.toolbar.panel}
getPopupContainer={getPopupContainer || ((triggerNode) => triggerNode.parentElement || document.body)}
onOpenChange={onOpenChange}
content={<ImageSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} />}
>
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
<span className="truncate">
{imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {count}
</span>
</Button>
</Popover>
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[180px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => updateOpen(!open)}>
<span className="truncate">
{imageQualityLabel(quality)} · {imageSizeLabel(activeSize)} · {count}
</span>
</Button>
</span>
{panel}
</>
);
}
function ImageSettingsPortal({
buttonRect,
panelRef,
placement,
theme,
config,
onConfigChange,
}: {
buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasImageSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig;
onConfigChange: (key: keyof AiConfig, value: string) => void;
}) {
const width = 356;
const gap = 8;
const margin = 12;
const alignRight = placement?.endsWith("Right");
const alignCenter = placement === "top" || placement === "bottom";
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
const topPlacement = placement?.startsWith("top");
const style = {
position: "fixed",
zIndex: 1200,
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),
background: theme.toolbar.panel,
borderRadius: 18,
boxShadow: "0 18px 54px rgba(28, 25, 23, 0.16)",
padding: 18,
overflowY: "auto",
color: theme.node.text,
} as const;
return createPortal(
<div
ref={panelRef}
className="canvas-image-settings-popover"
style={style}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<ImageSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} className="space-y-4" />
</div>,
document.body,
);
}
@@ -6,6 +6,7 @@ import { Button, Input } from "antd";
import { useCanvasStore, type CanvasProject } from "../stores/use-canvas-store";
import { useCanvasUiStore } from "../stores/use-canvas-ui-store";
import { exportCanvasProjects } from "../utils/canvas-export";
export function CanvasProjectCard({ project }: { project: CanvasProject }) {
const router = useRouter();
@@ -65,7 +66,7 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
</>
) : (
<>
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => exportProject(project)} aria-label="导出" />
<Button type="text" size="small" shape="circle" icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects([project], project.title || "无限画布")} aria-label="导出" />
<Button type="text" size="small" shape="circle" icon={<Pencil className="size-4" />} onClick={() => startEditing(project.id, project.title)} aria-label="重命名" />
<Button type="text" size="small" shape="circle" icon={<Trash2 className="size-4" />} onClick={() => setDeleteIds([project.id])} aria-label="删除" />
</>
@@ -75,13 +76,3 @@ export function CanvasProjectCard({ project }: { project: CanvasProject }) {
</article>
);
}
function exportProject(project: CanvasProject) {
const data = { app: "infinite-canvas", version: 1, exportedAt: new Date().toISOString(), project };
const url = URL.createObjectURL(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }));
const link = document.createElement("a");
link.href = url;
link.download = `${(project.title || "无限画布").replace(/[\\/:*?"<>|]/g, "_")}.json`;
link.click();
URL.revokeObjectURL(url);
}
@@ -1,7 +1,9 @@
"use client";
import { useEffect, useRef, useState, type RefObject } from "react";
import { createPortal } from "react-dom";
import { Settings2 } from "lucide-react";
import { Button, Popover } from "antd";
import { Button } from "antd";
import { VideoSettingsPanel, videoResolutionLabel, videoSecondsLabel, videoSizeLabel } from "@/components/video-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
@@ -17,23 +19,95 @@ type CanvasVideoSettingsPopoverProps = {
export function CanvasVideoSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasVideoSettingsPopoverProps) {
const theme = canvasThemes[useThemeStore((state) => state.theme)];
const buttonRef = useRef<HTMLSpanElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [buttonRect, setButtonRect] = useState<DOMRect | null>(null);
useEffect(() => {
if (!open) return;
const syncPosition = () => setButtonRect(buttonRef.current?.getBoundingClientRect() || null);
const closeOnOutsidePointer = (event: PointerEvent) => {
const target = event.target;
if (!(target instanceof Node)) return;
if (buttonRef.current?.contains(target) || panelRef.current?.contains(target)) return;
setOpen(false);
};
syncPosition();
window.addEventListener("resize", syncPosition);
window.addEventListener("scroll", syncPosition, true);
window.addEventListener("pointerdown", closeOnOutsidePointer, true);
return () => {
window.removeEventListener("resize", syncPosition);
window.removeEventListener("scroll", syncPosition, true);
window.removeEventListener("pointerdown", closeOnOutsidePointer, true);
};
}, [open]);
const panel = open && buttonRect ? <VideoSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
return (
<Popover
trigger="click"
placement={placement}
arrow={false}
overlayClassName="canvas-image-settings-popover"
color={theme.toolbar.panel}
zIndex={1200}
getPopupContainer={() => document.body}
content={<VideoSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} />}
>
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />}>
<span className="truncate">
{videoResolutionLabel(config.vquality)} · {videoSizeLabel(config.size)} · {videoSecondsLabel(config.videoSeconds)}
</span>
</Button>
</Popover>
<>
<span ref={buttonRef} className="inline-flex min-w-0">
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
<span className="truncate">
{videoResolutionLabel(config.vquality)} · {videoSizeLabel(config.size)} · {videoSecondsLabel(config.videoSeconds)}
</span>
</Button>
</span>
{panel}
</>
);
}
function VideoSettingsPortal({
buttonRect,
panelRef,
placement,
theme,
config,
onConfigChange,
}: {
buttonRect: DOMRect;
panelRef: RefObject<HTMLDivElement | null>;
placement: CanvasVideoSettingsPopoverProps["placement"];
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
config: AiConfig;
onConfigChange: (key: keyof AiConfig, value: string) => void;
}) {
const width = 356;
const gap = 8;
const margin = 12;
const alignRight = placement?.endsWith("Right");
const alignCenter = placement === "top" || placement === "bottom";
const left = alignCenter ? buttonRect.left + buttonRect.width / 2 - width / 2 : alignRight ? buttonRect.right - width : buttonRect.left;
const topPlacement = placement?.startsWith("top");
const style = {
position: "fixed",
zIndex: 1200,
width,
left: Math.max(margin, Math.min(window.innerWidth - width - margin, left)),
...(topPlacement ? { bottom: window.innerHeight - buttonRect.top + gap, maxHeight: Math.max(260, buttonRect.top - margin * 2) } : { top: buttonRect.bottom + gap, maxHeight: Math.max(260, window.innerHeight - buttonRect.bottom - margin * 2) }),
background: theme.toolbar.panel,
borderRadius: 18,
boxShadow: "0 18px 54px rgba(28, 25, 23, 0.16)",
padding: 18,
overflowY: "auto",
color: theme.node.text,
} as const;
return createPortal(
<div
ref={panelRef}
className="canvas-image-settings-popover"
style={style}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
>
<VideoSettingsPanel config={config} onConfigChange={(key, value) => onConfigChange(key, value)} theme={theme} className="space-y-4" />
</div>,
document.body,
);
}
+20
View File
@@ -0,0 +1,20 @@
import type { CanvasProject } from "./stores/use-canvas-store";
export type CanvasExportFile = {
app: "infinite-canvas";
version: 3;
exportedAt: string;
projects: CanvasProjectExportItem[];
};
export type CanvasProjectExportItem = {
project: CanvasProject;
files: CanvasExportAsset[];
};
export type CanvasExportAsset = {
storageKey: string;
path: string;
mimeType: string;
bytes: number;
};
+33 -17
View File
@@ -3,19 +3,17 @@
import { useRef } from "react";
import { useRouter } from "next/navigation";
import { App, Button } from "antd";
import { FileUp, Plus } from "lucide-react";
import { Download, FileUp, Plus } from "lucide-react";
import { readZip } from "@/lib/zip";
import { setMediaBlob } from "@/services/file-storage";
import { setImageBlob } from "@/services/image-storage";
import { CanvasDeleteProjectsDialog } from "./components/canvas-delete-projects-dialog";
import { CanvasProjectCard } from "./components/canvas-project-card";
import { useCanvasStore, type CanvasProject } from "./stores/use-canvas-store";
import type { CanvasExportFile } from "./export-types";
import { useCanvasStore } from "./stores/use-canvas-store";
import { useCanvasUiStore } from "./stores/use-canvas-ui-store";
type CanvasExportFile = {
app: "infinite-canvas";
version: number;
exportedAt: string;
project: CanvasProject;
};
import { exportCanvasProjects } from "./utils/canvas-export";
export default function CanvasPage() {
const { message } = App.useApp();
@@ -35,11 +33,24 @@ export default function CanvasPage() {
const importCanvas = async (file?: File) => {
if (!file) return;
try {
const data = JSON.parse(await file.text()) as CanvasExportFile;
enterProject(importProject(data.project));
message.success("画布已导入");
const zip = await readZip(file);
const projectFile = zip.get("projects.json");
if (!projectFile) throw new Error("missing projects.json");
const data = JSON.parse(await projectFile.text()) as CanvasExportFile;
await Promise.all(
data.projects.flatMap((project) =>
project.files.map(async (item) => {
const blob = zip.get(item.path);
if (!blob) return;
const typedBlob = blob.type ? blob : blob.slice(0, blob.size, item.mimeType);
await (item.storageKey.startsWith("image:") ? setImageBlob(item.storageKey, typedBlob) : setMediaBlob(item.storageKey, typedBlob));
}),
),
);
data.projects.forEach((item) => importProject(item.project));
message.success(`已导入 ${data.projects.length} 个画布`);
} catch {
message.error("导入失败,请选择有效的 JSON 文件");
message.error("导入失败,请选择有效的画布压缩包");
} finally {
if (inputRef.current) inputRef.current.value = "";
}
@@ -55,9 +66,14 @@ export default function CanvasPage() {
</div>
<div className="flex items-center gap-2">
{selectedIds.length ? (
<Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
</Button>
<>
<Button disabled={!hydrated} icon={<Download className="size-4" />} onClick={() => void exportCanvasProjects(projects.filter((project) => selectedIds.includes(project.id)), `无限画布-${selectedIds.length}个项目`)}>
</Button>
<Button disabled={!hydrated} onClick={() => setDeleteIds(selectedIds)}>
</Button>
</>
) : null}
{projects.length ? (
<Button disabled={!hydrated} onClick={() => setDeleteIds(projects.map((project) => project.id))}>
@@ -92,7 +108,7 @@ export default function CanvasPage() {
)}
</div>
<input ref={inputRef} type="file" accept="application/json,.json" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<input ref={inputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
<CanvasDeleteProjectsDialog />
</main>
);
@@ -0,0 +1,51 @@
import { saveAs } from "file-saver";
import { createZip } from "@/lib/zip";
import { getMediaBlob } from "@/services/file-storage";
import { getImageBlob } from "@/services/image-storage";
import type { CanvasExportAsset, CanvasExportFile } from "../export-types";
import type { CanvasProject } from "../stores/use-canvas-store";
export async function exportCanvasProjects(projects: CanvasProject[], fileName = "无限画布") {
const zipFiles: { name: string; data: BlobPart }[] = [];
const exportedProjects = await Promise.all(
projects.map(async (project) => {
const files: CanvasExportAsset[] = [];
await Promise.all(
collectStorageKeys(project).map(async (storageKey) => {
const blob = storageKey.startsWith("image:") ? await getImageBlob(storageKey) : await getMediaBlob(storageKey);
if (!blob) return;
const path = `projects/${project.id}/files/${safeFileName(storageKey)}.${fileExtension(blob.type, storageKey)}`;
files.push({ storageKey, path, mimeType: blob.type || "application/octet-stream", bytes: blob.size });
zipFiles.push({ name: path, data: blob });
}),
);
return { project, files };
}),
);
const data: CanvasExportFile = { app: "infinite-canvas", version: 3, exportedAt: new Date().toISOString(), projects: exportedProjects };
const zip = await createZip([{ name: "projects.json", data: JSON.stringify(data, null, 2) }, ...zipFiles]);
saveAs(zip, `${safeFileName(fileName)}.zip`);
}
function collectStorageKeys(value: unknown, keys = new Set<string>()) {
if (!value || typeof value !== "object") return [...keys];
if ("storageKey" in value && typeof value.storageKey === "string" && value.storageKey.includes(":")) keys.add(value.storageKey);
Object.values(value).forEach((item) => (Array.isArray(item) ? item.forEach((child) => collectStorageKeys(child, keys)) : collectStorageKeys(item, keys)));
return [...keys];
}
function safeFileName(value: string) {
return value.replace(/[\\/:*?"<>|]/g, "_");
}
function fileExtension(mimeType: string, storageKey: string) {
if (mimeType.includes("png")) return "png";
if (mimeType.includes("jpeg")) return "jpg";
if (mimeType.includes("webp")) return "webp";
if (mimeType.includes("gif")) return "gif";
if (mimeType.includes("mp4")) return "mp4";
if (mimeType.includes("webm")) return "webm";
return storageKey.startsWith("image:") ? "png" : "bin";
}
+93 -46
View File
@@ -4,6 +4,7 @@ import { BookOpen, CheckSquare, ClipboardPaste, Download, FolderPlus, History, I
import { useEffect, useRef, useState } from "react";
import { App, Button, Checkbox, Drawer, Empty, Image, Input, Modal, Tag, Typography } from "antd";
import localforage from "localforage";
import { saveAs } from "file-saver";
import { ImageSettingsPanel } from "@/components/image-settings-panel";
import { ModelPicker } from "@/components/model-picker";
@@ -15,17 +16,19 @@ import { useThemeStore } from "@/stores/use-theme-store";
import { nanoid } from "nanoid";
import { formatBytes, formatDuration, getDataUrlByteSize, readImageMeta } from "@/lib/image-utils";
import { requestEdit, requestGeneration } from "@/services/api/image";
import { uploadImage } from "@/services/image-storage";
import { deleteStoredImages, resolveImageUrl, uploadImage } from "@/services/image-storage";
import { useAssetStore } from "@/stores/use-asset-store";
import type { ReferenceImage } from "@/types/image";
type GeneratedImage = {
id: string;
dataUrl: string;
storageKey?: string;
durationMs: number;
width: number;
height: number;
bytes: number;
mimeType?: string;
};
type GenerationResult = {
@@ -39,8 +42,11 @@ type GenerationLog = {
id: string;
createdAt: number;
title: string;
prompt: string;
time: string;
model: string;
config: GenerationLogConfig;
references: ReferenceImage[];
durationMs: number;
successCount: number;
failCount: number;
@@ -48,13 +54,15 @@ type GenerationLog = {
size: string;
quality: string;
status: "成功" | "失败";
images: GeneratedImage[];
thumbnails: string[];
};
type GenerationLogConfig = Pick<AiConfig, "model" | "imageModel" | "quality" | "size" | "count">;
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
const LOG_STORE_KEY = "infinite-canvas:image_generation_logs";
const LOG_STORE_PREFIX = `${LOG_STORE_KEY}:`;
const logStore = localforage.createInstance({ name: "infinite-canvas", storeName: "image_generation_logs" });
export default function ImagePage() {
@@ -158,16 +166,23 @@ export default function ImagePage() {
const failed = result.find((item): item is PromiseRejectedResult => item.status === "rejected");
try {
const logImages = await Promise.all(
successImages.map(async (image) => {
const stored = await uploadImage(image.dataUrl);
return { ...image, dataUrl: stored.url, storageKey: stored.storageKey, width: stored.width, height: stored.height, bytes: stored.bytes, mimeType: stored.mimeType };
}),
);
saveLog(
buildLog({
prompt: text,
model,
config: { ...snapshot.config, count: String(generationCount) },
references: snapshot.references,
durationMs: performance.now() - batchStartedAt,
successCount,
failCount,
status: successCount ? "成功" : "失败",
thumbnails: successImages.map((image) => image.dataUrl),
images: logImages,
}),
);
successCount ? message.success("图片已生成") : message.error(failed?.reason instanceof Error ? failed.reason.message : "生成失败");
@@ -177,10 +192,7 @@ export default function ImagePage() {
};
const downloadImage = (image: GeneratedImage, index: number) => {
const link = document.createElement("a");
link.href = image.dataUrl;
link.download = `image-${index + 1}.png`;
link.click();
saveAs(image.dataUrl, `image-${index + 1}.png`);
};
const addResultToReferences = async (image: GeneratedImage, index: number) => {
@@ -224,7 +236,8 @@ export default function ImagePage() {
};
const deleteSelectedLogs = () => {
void Promise.all(selectedLogIds.map((id) => logStore.removeItem(id))).then(refreshLogs);
const imageKeys = logs.filter((log) => selectedLogIds.includes(log.id)).flatMap((log) => log.images.map((image) => image.storageKey).filter((key): key is string => Boolean(key)));
void Promise.all([deleteStoredImages(imageKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(refreshLogs);
if (previewLog && selectedLogIds.includes(previewLog.id)) {
setPreviewLog(null);
setResults([]);
@@ -234,7 +247,7 @@ export default function ImagePage() {
};
const saveLog = (log: GenerationLog) => {
void logStore.setItem(log.id, log).then(refreshLogs);
void logStore.setItem(log.id, serializeLog(log)).then(refreshLogs);
};
const refreshLogs = async () => setLogs(await readStoredLogs());
@@ -242,13 +255,13 @@ export default function ImagePage() {
const previewGenerationLog = async (log: GenerationLog) => {
setPreviewLog(log);
setLogsOpen(false);
const images = await Promise.all(
log.thumbnails.map(async (dataUrl, index) => {
const meta = await readImageMeta(dataUrl);
return { id: `${log.id}-${index}`, dataUrl, durationMs: log.durationMs, width: meta.width, height: meta.height, bytes: getDataUrlByteSize(dataUrl) };
}),
);
setResults(images.map((image) => ({ id: image.id, status: "success", image })));
setPrompt(log.prompt);
setReferences(log.references || []);
if (log.config.imageModel || log.model) updateConfig("imageModel", log.config.imageModel || log.model);
if (log.config.quality) updateConfig("quality", log.config.quality);
if (log.config.size) updateConfig("size", log.config.size);
if (log.config.count) updateConfig("count", log.config.count);
setResults(log.images.map((image) => ({ id: image.id, status: "success", image })));
};
const buildRequestSnapshot = () => {
@@ -662,47 +675,68 @@ function LogCard({ log, selected, active, onSelectedChange, onClick }: { log: Ge
async function readStoredLogs() {
if (typeof window === "undefined") return [];
try {
const legacyValue = window.localStorage.getItem(LOG_STORE_KEY);
if (legacyValue) {
await Promise.all((JSON.parse(legacyValue) as GenerationLog[]).map((log) => logStore.setItem(log.id, normalizeLog(log))));
window.localStorage.removeItem(LOG_STORE_KEY);
}
const legacyKeys = Array.from({ length: window.localStorage.length }, (_, index) => window.localStorage.key(index)).filter((key): key is string => Boolean(key?.startsWith(LOG_STORE_PREFIX)));
await Promise.all(
legacyKeys.map(async (key) => {
try {
const log = normalizeLog(JSON.parse(window.localStorage.getItem(key) || ""));
await logStore.setItem(log.id, log);
window.localStorage.removeItem(key);
} catch {}
}),
);
const logs: GenerationLog[] = [];
const values: GenerationLog[] = [];
await logStore.iterate<GenerationLog, void>((value) => {
logs.push(normalizeLog(value));
values.push(value);
});
const logs = await Promise.all(values.map(normalizeLog));
return logs.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
} catch {
return [];
}
}
function normalizeLog(log: Partial<GenerationLog>): GenerationLog {
async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog> {
const references = await Promise.all(
(log.references || []).map(async (item) => ({
...item,
dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl),
})),
);
const images = await Promise.all(
(log.images || []).map(async (item) => ({
...item,
dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl),
})),
);
const config = normalizeLogConfig(log);
return {
id: log.id || nanoid(),
createdAt: log.createdAt || Date.now(),
title: log.title || log.model || "未命名",
prompt: log.prompt || log.title || "",
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }),
model: log.model || "",
model: log.model || config.imageModel || "",
config,
references,
durationMs: log.durationMs || 0,
successCount: log.successCount ?? log.imageCount ?? 0,
failCount: log.failCount || 0,
imageCount: log.imageCount || log.successCount || 0,
size: log.size || "",
quality: log.quality || "",
size: log.size || config.size || "",
quality: log.quality || config.quality || "",
status: log.status || "成功",
thumbnails: log.thumbnails || [],
images,
thumbnails: images.map((image) => image.dataUrl),
};
}
function serializeLog(log: GenerationLog): GenerationLog {
return {
...log,
references: log.references.map((item) => ({ ...item, dataUrl: item.storageKey ? "" : item.dataUrl })),
images: log.images.map((image) => ({ ...image, dataUrl: image.storageKey ? "" : image.dataUrl })),
thumbnails: [],
};
}
function normalizeLogConfig(log: Partial<GenerationLog>): GenerationLogConfig {
return {
model: log.config?.model || log.model || "",
imageModel: log.config?.imageModel || log.model || "",
quality: log.config?.quality || log.quality || "",
size: log.config?.size || log.size || "",
count: log.config?.count || String(log.imageCount || log.successCount || 1),
};
}
@@ -710,34 +744,47 @@ function buildLog({
prompt,
model,
config,
references,
durationMs,
successCount,
failCount,
status,
thumbnails,
images,
}: {
prompt: string;
model: string;
config: { size: string; quality: string; count?: string };
config: GenerationLogConfig;
references: ReferenceImage[];
durationMs: number;
successCount: number;
failCount: number;
status: GenerationLog["status"];
thumbnails: string[];
images: GeneratedImage[];
}): GenerationLog {
const logConfig = {
model: config.model,
imageModel: config.imageModel,
quality: config.quality,
size: config.size,
count: config.count,
};
return {
id: nanoid(),
createdAt: Date.now(),
title: prompt.slice(0, 12) || "未命名",
prompt,
time: new Date().toLocaleString("zh-CN", { hour12: false }),
model,
config: logConfig,
references,
durationMs,
successCount,
failCount,
imageCount: Number(config.count) || successCount,
size: config.size,
quality: config.quality,
imageCount: Number(logConfig.count) || successCount,
size: logConfig.size,
quality: logConfig.quality,
status,
thumbnails,
images,
thumbnails: images.map((image) => image.dataUrl),
};
}
+69 -18
View File
@@ -5,6 +5,7 @@ import { useEffect, useRef, useState } from "react";
import { App, Button, Checkbox, Drawer, Empty, Input, Modal, Tag, Typography } from "antd";
import localforage from "localforage";
import { nanoid } from "nanoid";
import { saveAs } from "file-saver";
import { AssetPickerModal, type InsertAssetPayload } from "@/app/(user)/canvas/components/asset-picker-modal";
import { ModelPicker } from "@/components/model-picker";
@@ -12,8 +13,8 @@ import { PromptSelectDialog } from "@/components/prompts/prompt-select-dialog";
import { VideoSettingsPanel, normalizeVideoResolutionValue, normalizeVideoSizeValue, videoSizeLabel } from "@/components/video-settings-panel";
import { canvasThemes } from "@/lib/canvas-theme";
import { formatBytes, formatDuration } from "@/lib/image-utils";
import { resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
import { uploadImage } from "@/services/image-storage";
import { deleteStoredMedia, resolveMediaUrl, uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage } from "@/services/image-storage";
import { requestVideoGeneration } from "@/services/api/video";
import { useAssetStore } from "@/stores/use-asset-store";
import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
@@ -42,8 +43,11 @@ type GenerationLog = {
id: string;
createdAt: number;
title: string;
prompt: string;
time: string;
model: string;
config: GenerationLogConfig;
references: ReferenceImage[];
durationMs: number;
size: string;
resolution: string;
@@ -53,6 +57,8 @@ type GenerationLog = {
error?: string;
};
type GenerationLogConfig = Pick<AiConfig, "model" | "videoModel" | "size" | "vquality" | "videoSeconds">;
type UpdateAiConfig = <K extends keyof AiConfig>(key: K, value: AiConfig[K]) => void;
const LOG_STORE_KEY = "infinite-canvas:video_generation_logs";
@@ -150,12 +156,12 @@ export default function VideoPage() {
mimeType: stored.mimeType,
};
setResults([{ id: nextVideo.id, status: "success", video: nextVideo }]);
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo }));
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, durationMs: nextVideo.durationMs, status: "成功", video: nextVideo }));
message.success("视频已生成");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "生成失败";
setResults([{ id: nanoid(), status: "failed", error: errorMessage }]);
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
saveLog(buildLog({ prompt: snapshot.text, model, config: snapshot.config, references: snapshot.references, durationMs: performance.now() - batchStartedAt, status: "失败", error: errorMessage }));
message.error(errorMessage);
} finally {
setRunning(false);
@@ -181,10 +187,7 @@ export default function VideoPage() {
};
const downloadVideo = (video: GeneratedVideo) => {
const link = document.createElement("a");
link.href = video.url;
link.download = "video.mp4";
link.click();
saveAs(video.url, "video.mp4");
};
const saveResultToAssets = (video: GeneratedVideo) => {
@@ -221,7 +224,11 @@ export default function VideoPage() {
};
const deleteSelectedLogs = () => {
void Promise.all(selectedLogIds.map((id) => logStore.removeItem(id))).then(refreshLogs);
const mediaKeys = logs
.filter((log) => selectedLogIds.includes(log.id))
.map((log) => log.video?.storageKey)
.filter((key): key is string => Boolean(key));
void Promise.all([deleteStoredMedia(mediaKeys), ...selectedLogIds.map((id) => logStore.removeItem(id))]).then(refreshLogs);
if (previewLog && selectedLogIds.includes(previewLog.id)) {
setPreviewLog(null);
setResults([]);
@@ -231,7 +238,7 @@ export default function VideoPage() {
};
const saveLog = (log: GenerationLog) => {
void logStore.setItem(log.id, log).then(refreshLogs);
void logStore.setItem(log.id, serializeLog(log)).then(refreshLogs);
};
const refreshLogs = async () => setLogs(await readStoredLogs());
@@ -239,6 +246,12 @@ export default function VideoPage() {
const previewGenerationLog = (log: GenerationLog) => {
setPreviewLog(log);
setLogsOpen(false);
setPrompt(log.prompt);
setReferences(log.references || []);
if (log.config.videoModel || log.model) updateConfig("videoModel", log.config.videoModel || log.model);
if (log.config.size) updateConfig("size", log.config.size);
if (log.config.vquality) updateConfig("vquality", log.config.vquality);
if (log.config.videoSeconds) updateConfig("videoSeconds", log.config.videoSeconds);
setResults(log.video ? [{ id: log.video.id, status: "success", video: log.video }] : [{ id: log.id, status: "failed", error: log.error || "生成失败" }]);
};
@@ -529,33 +542,71 @@ async function readStoredLogs() {
async function normalizeLog(log: Partial<GenerationLog>): Promise<GenerationLog> {
const video = log.video?.storageKey ? { ...log.video, url: await resolveMediaUrl(log.video.storageKey, log.video.url) } : log.video;
const references = await Promise.all(
(log.references || []).map(async (item) => ({
...item,
dataUrl: await resolveImageUrl(item.storageKey, item.dataUrl),
})),
);
const config = normalizeLogConfig(log);
return {
id: log.id || nanoid(),
createdAt: log.createdAt || Date.now(),
title: log.title || log.model || "未命名",
prompt: log.prompt || "",
time: log.time || new Date().toLocaleString("zh-CN", { hour12: false }),
model: log.model || "",
model: log.model || config.videoModel || "",
config,
references,
durationMs: log.durationMs || 0,
size: log.size || "",
resolution: normalizeResolution(log.resolution || ""),
seconds: log.seconds || "",
size: log.size || config.size || "",
resolution: normalizeResolution(log.resolution || config.vquality || ""),
seconds: log.seconds || config.videoSeconds || "",
status: log.status || "成功",
video,
error: log.error,
};
}
function buildLog({ prompt, model, config, durationMs, status, video, error }: { prompt: string; model: string; config: AiConfig; durationMs: number; status: GenerationLog["status"]; video?: GeneratedVideo; error?: string }): GenerationLog {
function serializeLog(log: GenerationLog): GenerationLog {
return {
...log,
references: log.references.map((item) => ({ ...item, dataUrl: item.storageKey ? "" : item.dataUrl })),
video: log.video?.storageKey ? { ...log.video, url: "" } : log.video,
};
}
function normalizeLogConfig(log: Partial<GenerationLog>): GenerationLogConfig {
return {
model: log.config?.model || log.model || "",
videoModel: log.config?.videoModel || log.model || "",
size: log.config?.size || log.size || "",
vquality: normalizeResolution(log.config?.vquality || log.resolution || ""),
videoSeconds: log.config?.videoSeconds || log.seconds || "",
};
}
function buildLog({ prompt, model, config, references, durationMs, status, video, error }: { prompt: string; model: string; config: AiConfig; references: ReferenceImage[]; durationMs: number; status: GenerationLog["status"]; video?: GeneratedVideo; error?: string }): GenerationLog {
const logConfig = {
model: config.model,
videoModel: config.videoModel,
size: config.size,
vquality: normalizeResolution(config.vquality),
videoSeconds: config.videoSeconds,
};
return {
id: nanoid(),
createdAt: Date.now(),
title: prompt.slice(0, 12) || "未命名",
prompt,
time: new Date().toLocaleString("zh-CN", { hour12: false }),
model,
config: logConfig,
references,
durationMs,
size: config.size,
resolution: normalizeResolution(config.vquality),
seconds: config.videoSeconds,
size: logConfig.size,
resolution: logConfig.vquality,
seconds: logConfig.videoSeconds,
status,
video,
error,
+5
View File
@@ -405,6 +405,11 @@
padding: 18px;
}
.canvas-image-settings-popover .ant-popover-inner-content {
max-height: calc(100vh - 96px);
overflow-y: auto;
}
.canvas-image-settings-popover .ant-segmented {
border-radius: 999px;
padding: 3px;
+31 -1
View File
@@ -1,16 +1,22 @@
"use client";
import type { ReactNode } from "react";
import { useEffect } from "react";
import { useEffect, useRef } from "react";
import { usePathname } from "next/navigation";
import { App } from "antd";
import { useConfigStore } from "@/stores/use-config-store";
import { useUserStore } from "@/stores/use-user-store";
export function ClientRootInit({ children }: { children: ReactNode }) {
const { message } = App.useApp();
const handledConfigParams = useRef(false);
const pathname = usePathname();
const hydrateUser = useUserStore((state) => state.hydrateUser);
const loadPublicSettings = useConfigStore((state) => state.loadPublicSettings);
const publicSettings = useConfigStore((state) => state.publicSettings);
const updateConfig = useConfigStore((state) => state.updateConfig);
const openConfigDialog = useConfigStore((state) => state.openConfigDialog);
const isLoginPage = pathname === "/login" || pathname === "/admin/login";
useEffect(() => {
@@ -21,5 +27,29 @@ export function ClientRootInit({ children }: { children: ReactNode }) {
if (!isLoginPage) void hydrateUser();
}, [hydrateUser, isLoginPage]);
useEffect(() => {
if (handledConfigParams.current) return;
const searchParams = new URLSearchParams(window.location.search);
const baseUrl = searchParams.get("baseUrl") || searchParams.get("baseurl");
const apiKey = searchParams.get("apiKey") || searchParams.get("apikey");
if (!baseUrl && !apiKey) return;
if (!publicSettings) return;
handledConfigParams.current = true;
searchParams.delete("baseUrl");
searchParams.delete("baseurl");
searchParams.delete("apiKey");
searchParams.delete("apikey");
window.history.replaceState(null, "", `${window.location.pathname}${searchParams.size ? `?${searchParams}` : ""}${window.location.hash}`);
if (!publicSettings.modelChannel.allowCustomChannel) {
openConfigDialog(false);
message.error("后台未允许用户自定义渠道,请联系管理员进行配置");
return;
}
updateConfig("channelMode", "local");
if (baseUrl) updateConfig("baseUrl", baseUrl);
if (apiKey) updateConfig("apiKey", apiKey);
openConfigDialog(false);
}, [message, openConfigDialog, publicSettings, updateConfig]);
return <>{children}</>;
}
+21
View File
@@ -0,0 +1,21 @@
import { unzipSync, zipSync } from "fflate";
type ZipFile = {
name: string;
data: BlobPart;
};
export async function createZip(files: ZipFile[]) {
const entries = await Promise.all(
files.map(async (file) => {
const data = new Uint8Array(await new Blob([file.data]).arrayBuffer());
return [file.name, data] as const;
}),
);
return new Blob([zipSync(Object.fromEntries(entries), { level: 0 })], { type: "application/zip" });
}
export async function readZip(file: Blob) {
const entries = unzipSync(new Uint8Array(await file.arrayBuffer()));
return new Map(Object.entries(entries).map(([name, data]) => [name, new Blob([data])]));
}
+22
View File
@@ -29,6 +29,28 @@ export async function resolveMediaUrl(storageKey?: string, fallback = "") {
return url;
}
export async function getMediaBlob(storageKey: string) {
return store.getItem<Blob>(storageKey);
}
export async function setMediaBlob(storageKey: string, blob: Blob) {
await store.setItem(storageKey, blob);
const url = URL.createObjectURL(blob);
objectUrls.set(storageKey, url);
return url;
}
export async function deleteStoredMedia(keys: Iterable<string>) {
await Promise.all(
Array.from(new Set(keys)).map(async (key) => {
const url = objectUrls.get(key);
if (url) URL.revokeObjectURL(url);
objectUrls.delete(key);
await store.removeItem(key);
}),
);
}
export async function cleanupUnusedMedia(usedData: unknown) {
const usedKeys = collectMediaStorageKeys(usedData);
const unused: string[] = [];
+11
View File
@@ -38,6 +38,17 @@ export async function resolveImageUrl(storageKey?: string, fallback = "") {
return url;
}
export async function getImageBlob(storageKey: string) {
return store.getItem<Blob>(storageKey);
}
export async function setImageBlob(storageKey: string, blob: Blob) {
await store.setItem(storageKey, blob);
const url = URL.createObjectURL(blob);
objectUrls.set(storageKey, url);
return url;
}
export async function imageToDataUrl(image: { url?: string; dataUrl?: string; storageKey?: string }) {
const url = image.dataUrl || (await resolveImageUrl(image.storageKey, image.url || ""));
if (!url || url.startsWith("data:")) return url;