mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 15:24:06 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 50a3a94876 | |||
| 6867f3e1e6 | |||
| e740da6702 | |||
| fcde508482 | |||
| 7e5179c54f | |||
| fda8efb362 | |||
| 7ac1fea662 | |||
| 005c396660 | |||
| bf66a25936 | |||
| de25ef515e | |||
| 9311b8a92b | |||
| 1fb782819b |
@@ -6,6 +6,7 @@
|
||||
|
||||
- 先读现有代码,再动手修改,优先沿用项目已有结构和写法。
|
||||
- 写代码保持最少行数,能简单实现就不要引入复杂抽象。
|
||||
- 标准格式、协议、解析、压缩、加密、日期等通用能力优先使用成熟稳定的库,不要手写底层实现,除非用户明确要求或项目已有实现必须沿用。
|
||||
- 不要为了“兼容更多场景”写大量分支,只实现当前明确需要的功能。
|
||||
- 项目尚未上线,不需要兼容旧数据;表结构或字段调整时直接按新设计修改,不写旧字段兼容、数据迁移兜底或删除旧表的清理逻辑,除非用户明确要求。
|
||||
- 每次写完代码,不需要检查语法,不需要执行构建,用户会自己做。
|
||||
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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">
|
||||
|
||||
+10
-2
@@ -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` 并可回填本次提示词、参考图和参数。
|
||||
- 视频创作台生成前会把模型、尺寸、秒数、清晰度归一化为视频接口支持的参数,并展示后端返回的错误信息,避免页面侧残留的生图参数影响视频请求。
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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])]));
|
||||
}
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user