feat(asset): 添加素材导入导出功能
- 实现了素材批量导出为包含 assets.json 和媒体文件的压缩包 - 实现了从压缩包导入素材到本地存储的功能 - 在素材页面添加了导出素材和导入素材的按钮入口 - 创建了 asset-transfer 模块处理素材打包和解包逻辑 - 支持图片和视频文件的存储路径映射和恢复 - 添加了文件安全命名和 MIME 类型识别功能
This commit is contained in:
@@ -3,6 +3,7 @@
|
|||||||
- 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。
|
- 画布项目导出改为下载 `.zip` 压缩包,包内包含 `projects.json` 和当前画布引用到的本地图片、视频文件,避免只导出 JSON 时丢失媒体内容。
|
||||||
- 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。
|
- 画布库支持多选后一键导出多个画布项目,导出的压缩包可一次恢复多个项目。
|
||||||
- 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。
|
- 画布项目导入改为读取新版 `.zip` 压缩包,会先按 `projects.json` 中的文件映射恢复图片、视频到本地存储,再插入画布项目,导入成功后仍停留在画布库。
|
||||||
|
- “我的素材”类型筛选区右侧新增文本样式的导出素材和导入素材入口,可将全部素材导出为包含 `assets.json` 与图片、视频文件的压缩包,并从压缩包恢复素材。
|
||||||
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
|
- 未登录状态下,画布右上角不再显示用户头像菜单、用户名称、算力点余额和退出登录入口,改为显示登录入口;快捷键入口仍可直接打开。
|
||||||
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
|
- 生图工作台的图片参数区复用画布里的紧凑版图像设置面板,尺寸、质量、生成张数的交互保持一致;工作台仍保留独立的模型选择。
|
||||||
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。
|
- 视频设置抽成画布和视频创作台共用的紧凑面板,清晰度、尺寸、秒数按固定网格选择并支持手动输入;尺寸选择 `auto` 时请求不传 `size`。
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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]);
|
||||||
|
const url = URL.createObjectURL(zip);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = "我的素材.zip";
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
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";
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils";
|
|||||||
import { uploadImage } from "@/services/image-storage";
|
import { uploadImage } from "@/services/image-storage";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
|
import { useAssetStore, type Asset, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
|
||||||
|
import { exportAssets, readAssetPackage } from "./asset-transfer";
|
||||||
|
|
||||||
type AssetFormValues = {
|
type AssetFormValues = {
|
||||||
kind: AssetKind;
|
kind: AssetKind;
|
||||||
@@ -35,6 +36,7 @@ export default function AssetsPage() {
|
|||||||
const [form] = Form.useForm<AssetFormValues>();
|
const [form] = Form.useForm<AssetFormValues>();
|
||||||
const coverInputRef = useRef<HTMLInputElement>(null);
|
const coverInputRef = useRef<HTMLInputElement>(null);
|
||||||
const imageInputRef = useRef<HTMLInputElement>(null);
|
const imageInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const assetInputRef = useRef<HTMLInputElement>(null);
|
||||||
const assets = useAssetStore((state) => state.assets);
|
const assets = useAssetStore((state) => state.assets);
|
||||||
const addAsset = useAssetStore((state) => state.addAsset);
|
const addAsset = useAssetStore((state) => state.addAsset);
|
||||||
const updateAsset = useAssetStore((state) => state.updateAsset);
|
const updateAsset = useAssetStore((state) => state.updateAsset);
|
||||||
@@ -153,6 +155,33 @@ export default function AssetsPage() {
|
|||||||
link.click();
|
link.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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 = () => {
|
const confirmDelete = () => {
|
||||||
if (!deletingAsset) return;
|
if (!deletingAsset) return;
|
||||||
removeAsset(deletingAsset.id);
|
removeAsset(deletingAsset.id);
|
||||||
@@ -208,9 +237,24 @@ export default function AssetsPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
onClick={openCreate}
|
||||||
>
|
>
|
||||||
新增素材
|
新增素材
|
||||||
@@ -218,6 +262,7 @@ export default function AssetsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
<div className="mx-auto flex max-w-7xl flex-col gap-5">
|
||||||
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
@@ -352,6 +397,8 @@ export default function AssetsPage() {
|
|||||||
|
|
||||||
<AssetDrawer asset={previewAsset} onClose={() => setPreviewAsset(null)} onCopy={copyAssetText} onDownload={downloadImage} />
|
<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="取消">
|
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={confirmDelete} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
|
||||||
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
|
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Reference in New Issue
Block a user