Skip to content

Commit 8d98ea9

Browse files
committed
feat(canvas): persist local assets and harden snapshots
1 parent 9d6dbac commit 8d98ea9

57 files changed

Lines changed: 2011 additions & 236 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,16 +37,21 @@ Open `http://localhost:3020`. Home is the default route. Opening Studio or Canva
3737
```text
3838
Studio / Canvas
3939
-> local server routes
40+
-> imported files saved under data/project-assets and indexed in SQLite
4041
-> generation precheck and a one-time SQLite generation intent
41-
-> just-in-time reference upload (only when Generate is confirmed)
42+
-> just-in-time provider upload of the durable local reference
4243
-> BeatAPI image or video task API
4344
-> provider status polling
4445
-> local generation history and asset index
4546
```
4647

47-
The API key and storage credentials remain server-side. Project state and history live in the local SQLite database. Generated files remain at the public URLs returned by the provider and are indexed locally instead of being copied again.
48+
The API key and storage credentials remain server-side. Project state and history live in the local SQLite database. In local SQLite mode, imported image and video files are copied immediately into the project-owned `data/project-assets/<project-id>/` directory and indexed in SQLite before a card is added to the Canvas. Generated files remain at the public URLs returned by the provider and are indexed locally instead of being copied again.
4849

49-
Selecting a local file creates a browser-local preview only. It does not upload anything. When the user confirms Generate, the server validates the project, model, prompt, concurrency, and BeatAPI connection, then creates a short-lived, one-time generation intent in SQLite. The intent fixes the project, model, and exact upload count. Uploaded URLs must be referenced by the same generation request, and the intent is consumed when the billable task is submitted. Uploaded inputs enter the project asset index only after BeatAPI accepts the task.
50+
Canvas state is saved as a complete project snapshot after changes, checked again every five seconds while dirty, and flushed when the page is hidden, refreshed, or closed. A populated snapshot cannot be replaced by an unconfirmed empty snapshot.
51+
52+
Selecting or dragging in a local file does not send it to a provider, but it is persisted immediately on the user's own machine so a refresh, restart, or development hot reload cannot lose it. When the user confirms Generate, the server validates the project, model, prompt, concurrency, and BeatAPI connection, then creates a short-lived, one-time generation intent in SQLite. The durable local reference is uploaded to the configured provider storage only for that confirmed generation. The intent fixes the project, model, and exact upload count, and is consumed when the billable task is submitted.
53+
54+
The `data/` directory is gitignored and contains both the SQLite database and local project assets. Back up or move the directory as one unit when migrating a workspace to another machine.
5055

5156
Storage entitlement follows BeatAPI billing. The built-in provider is fixed to the official `https://api.beatapi.io` endpoint and uses the user's BeatAPI API key. Supported image, audio, and subtitle inputs go through BeatAPI Files; an official hosted deployment can preconfigure managed R2 for video inputs that Files does not accept through the separate `BEATAPI_MANAGED_R2_*` secrets. Self-hosters may instead provide their own Cloudflare R2 or other S3-compatible endpoint, bucket, credentials, and public URL through `R2_*`. The two credential sets never fall through to each other.
5257

‎README.zh-CN.md‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,21 @@ pnpm dev
3535
```text
3636
Studio / Canvas
3737
-> 本地服务端接口
38+
-> 导入文件立即保存到 data/project-assets,并写入 SQLite 素材索引
3839
-> 生成预检与 SQLite 一次性生成意图
39-
-> 仅在确认生成后即时上传引用素材
40+
-> 仅在确认生成后,把已持久化的本地引用上传到 Provider
4041
-> BeatAPI 图片或视频任务接口
4142
-> 轮询 Provider 状态
4243
-> 写入本地生成历史与素材索引
4344
```
4445

45-
API Key 与存储密钥只保留在服务端。项目和历史数据保存在本地 SQLite。Provider 返回的公开结果地址会直接进入本地素材索引,不强制重复复制。
46+
API Key 与存储密钥只保留在服务端。项目和历史数据保存在本地 SQLite。在本地 SQLite 模式下,导入的图片和视频会先复制到项目自己的 `data/project-assets/<project-id>/` 目录,并写入 SQLite 素材索引,然后才加入 Canvas。Provider 返回的公开结果地址会直接进入本地素材索引,不强制重复复制。
4647

47-
用户选择本地文件时只会创建浏览器本地预览,不会上传。用户确认“生成”后,服务端先检查项目、模型、提示词、并发限制和 BeatAPI 连接,再在 SQLite 中创建短时、一次性的生成意图。意图会绑定项目、模型和准确上传数量;已上传地址必须出现在同一次生成请求中,并在提交付费任务时被消费。只有 BeatAPI 接受任务后,输入素材才会进入项目素材索引。
48+
Canvas 内容变化后会保存一份完整项目快照;存在未保存变化时,每 5 秒再检查一次,并在页面切到后台、刷新或关闭时强制落盘。已有内容的快照不会被未经确认的空快照覆盖。
49+
50+
用户选择文件或从 Finder 拖入文件时,不会立刻发送给 Provider,但会立即持久化在用户自己的电脑上,因此刷新、重启或开发热更新都不会丢失。用户确认“生成”后,服务端先检查项目、模型、提示词、并发限制和 BeatAPI 连接,再在 SQLite 中创建短时、一次性的生成意图;只有这时,已经持久化的本地引用才会上传到配置好的 Provider 存储。意图会绑定项目、模型和准确上传数量,并在提交付费任务时被消费。
51+
52+
`data/` 目录已被 Git 忽略,其中同时包含 SQLite 数据库和本地项目素材。把工作台迁移到另一台电脑时,应将整个目录一起备份或复制。
4853

4954
存储权益跟随 BeatAPI 计费:内置 Provider 固定通过官方 `https://api.beatapi.io` 接口,并使用用户自己的 BeatAPI API Key。图片、音频和字幕通过 BeatAPI Files 进入托管存储;官方部署通过独立的 `BEATAPI_MANAGED_R2_*` Secret 预配置 R2,处理 Files 暂不支持的视频输入。源码用户也可以通过 `R2_*` 配置自己的 Cloudflare R2 或其他 S3 兼容存储。两组凭据不会互相回退。
5055

‎messages/en.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@
367367
"tokens": {
368368
"quality": "Quality",
369369
"fast": "Fast",
370+
"lite": "Lite",
370371
"low": "Low",
371372
"medium": "Medium",
372373
"standard": "Standard",
@@ -528,6 +529,12 @@
528529
"triggerLabel": "Project assets",
529530
"title": "Project assets",
530531
"description": "Studio and Canvas share this same collection. Every completed generation and project upload appears here.",
532+
"canvasHint": "Click an asset to add it, or drag it anywhere onto the canvas.",
533+
"addToCanvas": "Add to canvas",
534+
"openOriginal": "Open original",
535+
"addedToCanvas": "Added to canvas",
536+
"imageName": "Project image",
537+
"videoName": "Project video",
531538
"loading": "Loading project assets...",
532539
"loadFailed": "Project assets could not be loaded.",
533540
"empty": "Generate or upload an asset to start this project collection.",

‎messages/zh.json‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,7 @@
368368
"tokens": {
369369
"quality": "质量",
370370
"fast": "快速",
371+
"lite": "轻量",
371372
"low": "低",
372373
"medium": "中",
373374
"standard": "标准",
@@ -529,6 +530,12 @@
529530
"triggerLabel": "项目素材",
530531
"title": "项目素材",
531532
"description": "Studio 与 Canvas 共用这组素材,项目内完成的生成结果和上传文件都会出现在这里。",
533+
"canvasHint": "点击素材即可加入,或直接拖到画布上的任意位置。",
534+
"addToCanvas": "加入画布",
535+
"openOriginal": "查看原文件",
536+
"addedToCanvas": "已加入画布",
537+
"imageName": "项目图片",
538+
"videoName": "项目视频",
532539
"loading": "正在加载项目素材...",
533540
"loadFailed": "暂时无法加载项目素材。",
534541
"empty": "生成或上传第一个素材,开始建立这个项目的素材集合。",

‎src/components/app/product-page-shell.tsx‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,12 @@ export function ProductPageShell({
156156
</Link>
157157
</div>
158158
) : null}
159-
{projectId ? <ProjectAssetsDialog projectId={projectId} /> : null}
159+
{projectId ? (
160+
<ProjectAssetsDialog
161+
projectId={projectId}
162+
canAddToCanvas={workspaceMode === 'canvas'}
163+
/>
164+
) : null}
160165
<WorkspaceApiConfigDialog
161166
providerId={envConfigs.generation_provider}
162167
/>

‎src/components/app/project-assets-dialog.tsx‎

Lines changed: 129 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,16 @@
11
'use client';
22

33
import { useQuery } from '@tanstack/react-query';
4-
import { FolderOpen, Images, Loader2, Video } from 'lucide-react';
4+
import {
5+
ExternalLink,
6+
FolderOpen,
7+
Images,
8+
Loader2,
9+
MousePointer2,
10+
Plus,
11+
Video,
12+
} from 'lucide-react';
13+
import { useState } from 'react';
514

615
import {
716
Dialog,
@@ -17,21 +26,67 @@ import {
1726
} from '@/core/workspace-lib/app/workspace-client-api';
1827
import { recentAssetsKeys } from '@/core/workspace-lib/app/workspace-query-keys';
1928
import { useTranslations } from '@/core/workspace-lib/shims/next-intl';
29+
import {
30+
PROJECT_ASSET_DRAG_MIME,
31+
PROJECT_ASSET_INSERT_EVENT,
32+
type ProjectAssetInsertDetail,
33+
type ProjectAssetTransfer,
34+
} from '@/core/beatcanvas/project-asset-transfer';
2035

2136
function AssetTile({
2237
asset,
2338
mediaType,
39+
projectId,
40+
canAddToCanvas,
41+
onAdded,
42+
addLabel,
43+
openLabel,
2444
}: {
2545
asset: RecentAsset;
2646
mediaType: 'image' | 'video';
47+
projectId: string;
48+
canAddToCanvas: boolean;
49+
onAdded: () => void;
50+
addLabel: string;
51+
openLabel: string;
2752
}) {
53+
const transfer: ProjectAssetTransfer = {
54+
...asset,
55+
projectId,
56+
mediaType,
57+
};
58+
59+
const addToCanvas = () => {
60+
if (!canAddToCanvas) return;
61+
window.dispatchEvent(
62+
new CustomEvent<ProjectAssetInsertDetail>(PROJECT_ASSET_INSERT_EVENT, {
63+
detail: { asset: transfer },
64+
})
65+
);
66+
onAdded();
67+
};
68+
2869
return (
29-
<a
30-
href={asset.publicUrl}
31-
target="_blank"
32-
rel="noreferrer"
33-
className="group relative aspect-square overflow-hidden rounded-[14px] border border-white/[0.08] bg-[#09090b] transition hover:-translate-y-0.5 hover:border-white/[0.22]"
34-
title={asset.filename || undefined}
70+
<div
71+
draggable={canAddToCanvas}
72+
onDragStart={(event) => {
73+
if (!canAddToCanvas) {
74+
event.preventDefault();
75+
return;
76+
}
77+
event.dataTransfer.effectAllowed = 'copy';
78+
event.dataTransfer.setData(
79+
PROJECT_ASSET_DRAG_MIME,
80+
JSON.stringify(transfer)
81+
);
82+
}}
83+
onDragEnd={(event) => {
84+
if (event.dataTransfer.dropEffect === 'copy') onAdded();
85+
}}
86+
className={`group relative aspect-square overflow-hidden rounded-[14px] border border-white/[0.08] bg-[#09090b] transition hover:-translate-y-0.5 hover:border-white/[0.22] ${
87+
canAddToCanvas ? 'cursor-grab active:cursor-grabbing' : ''
88+
}`}
89+
title={asset.filename || addLabel}
3590
>
3691
{mediaType === 'video' ? (
3792
<video
@@ -49,16 +104,59 @@ function AssetTile({
49104
/>
50105
)}
51106
{mediaType === 'video' ? (
52-
<span className="absolute bottom-2 right-2 grid size-7 place-items-center rounded-full border border-white/15 bg-black/65 text-white backdrop-blur">
107+
<span className="pointer-events-none absolute bottom-2 left-2 grid size-7 place-items-center rounded-full border border-white/15 bg-black/65 text-white backdrop-blur">
53108
<Video className="size-3.5" />
54109
</span>
55110
) : null}
56-
</a>
111+
112+
{canAddToCanvas ? (
113+
<button
114+
type="button"
115+
onClick={addToCanvas}
116+
className="absolute inset-0 flex items-end justify-center bg-gradient-to-t from-black/80 via-black/5 to-transparent px-2 pb-2 opacity-0 transition duration-200 group-hover:opacity-100 group-focus-within:opacity-100"
117+
aria-label={`${addLabel}: ${asset.filename || asset.id}`}
118+
>
119+
<span className="inline-flex h-7 items-center gap-1 rounded-full border border-white/15 bg-white/12 px-2.5 text-[10px] font-semibold text-white shadow-lg backdrop-blur-md">
120+
<Plus className="size-3" />
121+
{addLabel}
122+
</span>
123+
</button>
124+
) : (
125+
<a
126+
href={asset.publicUrl}
127+
target="_blank"
128+
rel="noreferrer"
129+
className="absolute inset-0"
130+
aria-label={`${openLabel}: ${asset.filename || asset.id}`}
131+
/>
132+
)}
133+
134+
{canAddToCanvas ? (
135+
<a
136+
href={asset.publicUrl}
137+
target="_blank"
138+
rel="noreferrer"
139+
onClick={(event) => event.stopPropagation()}
140+
className="absolute right-2 top-2 z-10 grid size-7 place-items-center rounded-full border border-white/15 bg-black/65 text-white/75 opacity-0 backdrop-blur transition hover:text-white group-hover:opacity-100 group-focus-within:opacity-100"
141+
aria-label={`${openLabel}: ${asset.filename || asset.id}`}
142+
title={openLabel}
143+
>
144+
<ExternalLink className="size-3.5" />
145+
</a>
146+
) : null}
147+
</div>
57148
);
58149
}
59150

60-
export function ProjectAssetsDialog({ projectId }: { projectId: string }) {
151+
export function ProjectAssetsDialog({
152+
projectId,
153+
canAddToCanvas = false,
154+
}: {
155+
projectId: string;
156+
canAddToCanvas?: boolean;
157+
}) {
61158
const t = useTranslations('AppShell.header.projectAssets');
159+
const [open, setOpen] = useState(false);
62160
const assetsQuery = useQuery({
63161
queryKey: recentAssetsKeys.lists(projectId),
64162
queryFn: () => fetchRecentAssets(projectId),
@@ -69,7 +167,7 @@ export function ProjectAssetsDialog({ projectId }: { projectId: string }) {
69167
const total = images.length + videos.length;
70168

71169
return (
72-
<Dialog>
170+
<Dialog modal={false} open={open} onOpenChange={setOpen}>
73171
<DialogTrigger
74172
aria-label={t('triggerLabel')}
75173
title={t('triggerLabel')}
@@ -86,7 +184,10 @@ export function ProjectAssetsDialog({ projectId }: { projectId: string }) {
86184
) : null}
87185
</DialogTrigger>
88186

89-
<DialogContent className="max-h-[82vh] overflow-hidden rounded-[28px] border border-white/10 bg-[#111214] p-0 text-[#f5f5f7] shadow-[0_34px_110px_rgba(0,0,0,0.62)] ring-0 sm:max-w-[760px] [&_[data-slot=dialog-close]]:right-4 [&_[data-slot=dialog-close]]:top-4 [&_[data-slot=dialog-close]]:text-white/45 [&_[data-slot=dialog-close]]:hover:bg-white/[0.07] [&_[data-slot=dialog-close]]:hover:text-white">
187+
<DialogContent
188+
showOverlay={false}
189+
className="max-h-[82vh] overflow-hidden rounded-[28px] border border-white/10 bg-[#111214] p-0 text-[#f5f5f7] shadow-[0_34px_110px_rgba(0,0,0,0.62)] ring-0 sm:max-w-[760px] [&_[data-slot=dialog-close]]:right-4 [&_[data-slot=dialog-close]]:top-4 [&_[data-slot=dialog-close]]:text-white/45 [&_[data-slot=dialog-close]]:hover:bg-white/[0.07] [&_[data-slot=dialog-close]]:hover:text-white"
190+
>
90191
<DialogHeader className="border-b border-white/[0.08] px-6 pb-5 pt-6 text-left">
91192
<span className="mb-2 grid size-10 place-items-center rounded-[13px] border border-[#ff7a33]/25 bg-[#ff7a33]/10 text-[#ff8b4d]">
92193
<FolderOpen className="size-[18px]" />
@@ -97,6 +198,12 @@ export function ProjectAssetsDialog({ projectId }: { projectId: string }) {
97198
<DialogDescription className="max-w-xl text-[13px] leading-5 text-white/48">
98199
{t('description')}
99200
</DialogDescription>
201+
{canAddToCanvas ? (
202+
<div className="mt-2 inline-flex w-fit items-center gap-2 rounded-full border border-[#ff7a33]/18 bg-[#ff7a33]/[0.07] px-3 py-1.5 text-[11px] font-medium text-[#ffad7e]">
203+
<MousePointer2 className="size-3.5" />
204+
{t('canvasHint')}
205+
</div>
206+
) : null}
100207
</DialogHeader>
101208

102209
<div className="max-h-[60vh] overflow-y-auto px-5 py-5 sm:px-6">
@@ -137,6 +244,11 @@ export function ProjectAssetsDialog({ projectId }: { projectId: string }) {
137244
key={asset.id}
138245
asset={asset}
139246
mediaType="image"
247+
projectId={projectId}
248+
canAddToCanvas={canAddToCanvas}
249+
onAdded={() => setOpen(false)}
250+
addLabel={t('addToCanvas')}
251+
openLabel={t('openOriginal')}
140252
/>
141253
))}
142254
</div>
@@ -154,6 +266,11 @@ export function ProjectAssetsDialog({ projectId }: { projectId: string }) {
154266
key={asset.id}
155267
asset={asset}
156268
mediaType="video"
269+
projectId={projectId}
270+
canAddToCanvas={canAddToCanvas}
271+
onAdded={() => setOpen(false)}
272+
addLabel={t('addToCanvas')}
273+
openLabel={t('openOriginal')}
157274
/>
158275
))}
159276
</div>

‎src/components/beatcanvas/beatcanvas-composer-shell.test.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ const labels = {
9090
qualityOptionLabel: 'Quality',
9191
tokenQualityLabel: 'Quality',
9292
tokenFastLabel: 'Fast',
93+
tokenLiteLabel: 'Lite',
9394
tokenLowLabel: 'Low',
9495
tokenMediumLabel: 'Medium',
9596
tokenStandardLabel: 'Standard',

‎src/components/beatcanvas/beatcanvas-composer-utils.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const normalizeComposerToken = (
3434
CanvasLabels,
3535
| 'tokenQualityLabel'
3636
| 'tokenFastLabel'
37+
| 'tokenLiteLabel'
3738
| 'tokenLowLabel'
3839
| 'tokenMediumLabel'
3940
| 'tokenStandardLabel'
@@ -56,6 +57,8 @@ export const normalizeComposerToken = (
5657
return labels?.tokenQualityLabel ?? 'Quality';
5758
case 'fast':
5859
return labels?.tokenFastLabel ?? 'Fast';
60+
case 'lite':
61+
return labels?.tokenLiteLabel ?? 'Lite';
5962
case 'low':
6063
return labels?.tokenLowLabel ?? 'Low';
6164
case 'medium':

‎src/components/beatcanvas/beatcanvas-front-layer-context.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ type CanvasLabels = {
7979
qualityOptionLabel: string;
8080
tokenQualityLabel: string;
8181
tokenFastLabel: string;
82+
tokenLiteLabel: string;
8283
tokenLowLabel: string;
8384
tokenMediumLabel: string;
8485
tokenStandardLabel: string;
@@ -141,7 +142,6 @@ export type BeatCanvasFrontLayerValue = {
141142
}) => void;
142143
onSelectedShapeIdsChange: (shapeIds: string[]) => void;
143144
onSelectedCanvasCardIdsChange: (shapeIds: string[]) => void;
144-
onCanvasShapeIdsChange: (shapeIds: string[]) => void;
145145
onActiveComposerCardIdChange: (cardId: string | null) => void;
146146
onDraftPromptChange: (draftId: string, prompt: string) => void;
147147
onDraftTaskTypeChange: (

‎src/components/beatcanvas/beatcanvas-front-layer.tsx‎

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,6 @@ export function BeatCanvasFrontLayer() {
100100
onUndoCanvas,
101101
onRedoCanvas,
102102
onActiveComposerCardIdChange,
103-
onCanvasShapeIdsChange,
104103
onSelectedShapeIdsChange,
105104
onDraftAspectRatioChange,
106105
onDraftBackgroundSourceChange,
@@ -154,11 +153,6 @@ export function BeatCanvasFrontLayer() {
154153
[cards, selectedShapeIds]
155154
);
156155
const selectedCardIdsKey = selectedCardIds.join(',');
157-
const currentShapeIds = useCanvasEngineValue(
158-
(currentEditor) =>
159-
currentEditor.getCurrentPageShapes().map((shape) => shape.id),
160-
[cards]
161-
);
162156
const zoomLevel = useCanvasEngineValue(
163157
(currentEditor) =>
164158
Math.round(
@@ -197,10 +191,6 @@ export function BeatCanvasFrontLayer() {
197191
// eslint-disable-next-line react-hooks/exhaustive-deps -- only fire when the actual card IDs change, not on every cards object update
198192
}, [onSelectedCanvasCardIdsChange, selectedCardIdsKey]);
199193

200-
useEffect(() => {
201-
onCanvasShapeIdsChange(currentShapeIds);
202-
}, [currentShapeIds, onCanvasShapeIdsChange]);
203-
204194
useEffect(() => {
205195
if (
206196
typeof editor.getCameraOptions !== 'function' ||
@@ -808,7 +798,7 @@ export function BeatCanvasFrontLayer() {
808798
</button>
809799
</div>
810800

811-
{currentShapeIds.length === 0 ? (
801+
{Object.keys(cards).length === 0 ? (
812802
<div className="absolute inset-x-0 top-[46%] z-10 flex -translate-y-1/2 justify-center px-6">
813803
<StudioStartHere />
814804
</div>

0 commit comments

Comments
 (0)