Skip to content

Commit 94cfcac

Browse files
realZachiclaude
andauthored
feat(persistence): store images as disk-backed blobs and load workspaces lazily (#20)
* feat(persistence): store images as disk-backed blobs and load workspaces lazily Project documents no longer embed images as base64 data URLs. Image bytes now live as Blobs in a content-addressed IndexedDB asset store (SHA-256 ids), documents persist blob-asset: references, and the app renders object URLs — so image data stays out of the JS heap and identical images deduplicate across projects. Legacy documents migrate transparently on load and persist references on the next save. Startup no longer deserializes every project via getAll(): a cursor walk builds the project list one document at a time and only the active project is fully retained, so tab memory peaks at one project instead of the sum of all of them. Uploads store their file Blob directly (with a data URL fallback when Blob storage is unavailable), AI attachments register blob assets while keeping data URL provider payloads, and overlay reference images are converted back to base64 on demand. Deleting a project sweeps blobs no surviving project references. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: address review findings on run guarding and cache retention - Mark the AI generate run active before awaiting attachment preparation so a second click cannot start a parallel run, the close guard applies during preparation, cancellation is honored before the run starts, and preparation failures surface in the error view. - Drop the data-URL-to-asset-id memo map; it pinned full base64 strings in the heap for the tab lifetime, contradicting the goal of this change. Re-hashing the rare inline data URL on save is cheaper. - Guard openDatabase's failure path against a late error clearing a newer cached database promise after a retry. - Document the deliberate session-lifetime asset retention (unsaved state and undo history) and the cross-tab sweep limitation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7e27020 commit 94cfcac

16 files changed

Lines changed: 701 additions & 104 deletions

bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/ARCHITECTURE.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,16 @@ Values over roughly `52` are usually incorrect.
7070

7171
## Persistence
7272

73-
`src/persistence.ts` stores project data and uploaded assets in IndexedDB. A small local-storage migration path exists for legacy projects.
73+
`src/persistence.ts` stores project documents in IndexedDB (database opening and store names live in `src/persistence-db.ts`). A small local-storage migration path exists for legacy projects.
74+
75+
Image bytes are kept out of project documents and out of the JS heap:
76+
77+
- `src/asset-store.ts` stores every image as a Blob in a content-addressed `assets` store (SHA-256 id), which Chrome keeps disk-backed. Identical images deduplicate across projects automatically.
78+
- Persisted documents reference images as `blob-asset:<id>`; the running app renders object URLs. `src/asset-sources.ts` owns the pure walk over the fields that can carry an image source (uploads, background images, image elements, device screenshots).
79+
- Loading a project resolves references to object URLs; documents saved before the asset store existed carry inline data URLs, which are converted to stored Blobs on load and persisted as references on the next save.
80+
- Starting the app reads project summaries with a cursor, so only the active project document is fully retained in memory.
81+
- Deleting a project sweeps stored Blobs that no surviving project references. The sweep deliberately keeps every asset seen in the current session: unsaved in-memory state and undo history can reference assets no persisted document mentions, so those Blobs stay until a later session's sweep. Object URLs are never revoked for the same reason — they pin disk-backed Blobs, not heap memory.
82+
- Sweeping scans and deletes in separate transactions. Concurrent editing from a second tab is not a supported model (the database open already rejects when another tab blocks an upgrade); a save racing a delete across tabs could at worst strand one `blob-asset:` reference, which renders as a missing image.
7483

7584
Persistence is browser-local:
7685

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"eslint-plugin-import-x": "^4.17.1",
9393
"eslint-plugin-react-hooks": "latest",
9494
"eslint-plugin-react-refresh": "latest",
95+
"fake-indexeddb": "^6.2.5",
9596
"globals": "latest",
9697
"jsdom": "^29.1.1",
9798
"typescript": "5.9.3",

src/ai/overlay-asset-tools.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { generateImage, tool } from 'ai'
22
import { z } from 'zod'
3+
import { fileToDataUrl } from '../utils'
34
import { ASSET_CHROMA_KEY_HEX } from './chroma-key'
45
import {
56
OVERLAY_ASSET_BUDGET,
@@ -27,6 +28,14 @@ const dataUrlToBase64 = (dataUrl: string): string => {
2728
return dataUrl.slice(comma + 1)
2829
}
2930

31+
// Upload sources are object URLs backed by stored Blobs (data URLs only as a
32+
// storage fallback), so provider payloads fetch the bytes back before encoding.
33+
const sourceToBase64 = async (src: string): Promise<string> => {
34+
if (src.startsWith('data:')) return dataUrlToBase64(src)
35+
const blob = await (await fetch(src)).blob()
36+
return dataUrlToBase64(await fileToDataUrl(blob))
37+
}
38+
3039
const sanitizeAssetName = (name: string): string => {
3140
const trimmed = name.trim().replace(/[^\w.\- ]+/g, '').slice(0, 64)
3241
return trimmed.length > 0 ? trimmed : 'overlay-asset'
@@ -69,15 +78,19 @@ const toAssetModelOutput = (output: OverlayAssetToolResult) => {
6978
}
7079
}
7180

72-
const resolveReferenceImages = (
81+
const resolveReferenceImages = async (
7382
controller: ToolContext['controller'],
7483
referenceAssetIds: string[] | undefined,
75-
): { ok: true; images: string[] } | { ok: false; error: string } => {
84+
): Promise<{ ok: true; images: string[] } | { ok: false; error: string }> => {
7685
const images: string[] = []
7786
for (const assetId of referenceAssetIds ?? []) {
7887
const src = controller.getAssetSrc(assetId)
7988
if (!src) return notFound(assetNotFoundMessage(assetId))
80-
images.push(dataUrlToBase64(src))
89+
try {
90+
images.push(await sourceToBase64(src))
91+
} catch {
92+
return notFound(assetNotFoundMessage(assetId))
93+
}
8194
}
8295
return { ok: true, images }
8396
}
@@ -188,7 +201,7 @@ PROMPTING: describe ONLY the subject — one object, its material, style, colors
188201
}
189202
}
190203

191-
const references = resolveReferenceImages(controller, referenceAssetIds)
204+
const references = await resolveReferenceImages(controller, referenceAssetIds)
192205
if (!references.ok) return references
193206

194207
emit({ tool: 'create_overlay_asset' })

src/ai/use-ai-workflow.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { useCallback, useEffect, useRef, useState, type Dispatch, type RefObject, type SetStateAction } from 'react'
2+
import { importDataUrlAsset } from '../asset-store'
23
import { uid } from '../utils'
34
import { createAiController } from './controller'
45
import type { Slide, UploadAsset } from '../types'
@@ -127,21 +128,26 @@ export function useAiWorkflow({
127128
return () => window.clearTimeout(timer)
128129
}, [activity])
129130

130-
const prepareRun = useCallback((files: { name: string; dataUrl: string }[]) => {
131+
const prepareRun = useCallback(async (files: { name: string; dataUrl: string }[]) => {
131132
checkpoint()
132133
clearSelection()
133134
preRunSlideIds.current = new Set(slidesRef.current.map((slide) => slide.id))
134135
const bySource = new Map(uploadsRef.current.map((asset) => [asset.src, asset]))
135136
const additions: UploadAsset[] = []
136-
const prepared = files.map((file) => {
137-
let asset = bySource.get(file.dataUrl)
137+
const prepared: { assetId: string; name: string; dataUrl: string }[] = []
138+
for (const file of files) {
139+
// Uploads render from the stored Blob's object URL; the provider payload
140+
// keeps the original data URL because AI requests need base64 content.
141+
// Content-hashed storage makes re-attaching the same image dedupe here.
142+
const src = await importDataUrlAsset(file.dataUrl)
143+
let asset = bySource.get(src)
138144
if (!asset) {
139-
asset = { id: uid('upload'), name: file.name, src: file.dataUrl }
145+
asset = { id: uid('upload'), name: file.name, src }
140146
additions.push(asset)
141147
bySource.set(asset.src, asset)
142148
}
143-
return { assetId: asset.id, name: asset.name, dataUrl: asset.src }
144-
})
149+
prepared.push({ assetId: asset.id, name: asset.name, dataUrl: file.dataUrl })
150+
}
145151
if (additions.length > 0) {
146152
uploadsRef.current = [...additions, ...uploadsRef.current]
147153
setUploads(uploadsRef.current)

src/asset-sources.test.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { collectAssetSources, mapAssetSources } from './asset-sources'
3+
import type { Slide, UploadAsset } from './types'
4+
5+
const makeSlide = (overrides: Partial<Slide> = {}): Slide => ({
6+
id: 'slide-1',
7+
name: 'Screen 1',
8+
background: { type: 'solid', color1: '#000', color2: '#000', angle: 0 },
9+
elements: [],
10+
...overrides,
11+
})
12+
13+
const makeUpload = (src: string): UploadAsset => ({ id: 'upload-1', name: 'shot.png', src })
14+
15+
const projectFixture = () => ({
16+
uploads: [makeUpload('data:image/png;base64,AAA')],
17+
slides: [
18+
makeSlide({
19+
background: { type: 'image', color1: '#000', color2: '#000', angle: 0, image: 'data:image/png;base64,BBB' },
20+
elements: [
21+
{
22+
id: 'image-1', type: 'image' as const, x: 0, y: 0, width: 10, rotation: 0, opacity: 1,
23+
src: 'data:image/png;base64,AAA', borderRadius: 0,
24+
},
25+
{
26+
id: 'device-1', type: 'device' as const, x: 0, y: 0, width: 10, rotation: 0, opacity: 1,
27+
deviceStyle: 'iphone-17-a' as const, screenTheme: 'coral' as const, tiltX: 0, tiltY: 0, shadow: 0,
28+
screenshot: 'data:image/png;base64,CCC',
29+
},
30+
{
31+
id: 'shape-1', type: 'shape' as const, x: 0, y: 0, width: 10, rotation: 0, opacity: 1,
32+
shape: 'circle' as const, color: '#fff', strokeColor: '#000', strokeWidth: 0, shadow: 0,
33+
},
34+
],
35+
}),
36+
],
37+
})
38+
39+
describe('collectAssetSources', () => {
40+
it('collects uploads, background images, image elements, and device screenshots once each', () => {
41+
expect(collectAssetSources(projectFixture())).toEqual(new Set([
42+
'data:image/png;base64,AAA',
43+
'data:image/png;base64,BBB',
44+
'data:image/png;base64,CCC',
45+
]))
46+
})
47+
48+
it('skips absent optional sources and empty projects', () => {
49+
expect(collectAssetSources({ uploads: [], slides: [makeSlide()] })).toEqual(new Set())
50+
expect(collectAssetSources({ uploads: [], slides: [] })).toEqual(new Set())
51+
})
52+
})
53+
54+
describe('mapAssetSources', () => {
55+
it('rewrites every source field through the mapper', () => {
56+
const mapped = mapAssetSources(projectFixture(), (src) => `mapped:${src.slice(-3)}`)
57+
expect(mapped.uploads[0]?.src).toBe('mapped:AAA')
58+
expect(mapped.slides[0]?.background.image).toBe('mapped:BBB')
59+
expect(mapped.slides[0]?.elements[0]).toMatchObject({ src: 'mapped:AAA' })
60+
expect(mapped.slides[0]?.elements[1]).toMatchObject({ screenshot: 'mapped:CCC' })
61+
})
62+
63+
it('preserves untouched objects by identity so undo history keeps sharing structure', () => {
64+
const project = projectFixture()
65+
const identical = mapAssetSources(project, (src) => src)
66+
expect(identical.slides[0]).toBe(project.slides[0])
67+
expect(identical.uploads[0]).toBe(project.uploads[0])
68+
69+
const partial = mapAssetSources(project, (src) => src.endsWith('AAA') ? 'mapped:AAA' : src)
70+
expect(partial.slides[0]).not.toBe(project.slides[0])
71+
expect(partial.slides[0]?.background).toBe(project.slides[0]?.background)
72+
expect(partial.slides[0]?.elements[1]).toBe(project.slides[0]?.elements[1])
73+
expect(partial.slides[0]?.elements[2]).toBe(project.slides[0]?.elements[2])
74+
})
75+
})

src/asset-sources.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { CanvasElement, Slide, UploadAsset } from './types'
2+
3+
/**
4+
* Every place project data embeds an image source: upload assets, slide
5+
* background images, image elements, and device screenshots. AI-authored
6+
* `slide.html` is excluded on purpose — it references uploads through
7+
* `asset:` upload ids and may only embed small self-authored SVG data URIs.
8+
*/
9+
export type ProjectAssetSources = {
10+
slides: Slide[]
11+
uploads: UploadAsset[]
12+
}
13+
14+
export const collectAssetSources = ({ slides, uploads }: ProjectAssetSources): Set<string> => {
15+
const sources = new Set<string>()
16+
for (const upload of uploads) sources.add(upload.src)
17+
for (const slide of slides) {
18+
if (slide.background.image) sources.add(slide.background.image)
19+
for (const element of slide.elements) {
20+
if (element.type === 'image') sources.add(element.src)
21+
else if (element.type === 'device' && element.screenshot) sources.add(element.screenshot)
22+
}
23+
}
24+
return sources
25+
}
26+
27+
const mapElementSources = (element: CanvasElement, map: (src: string) => string): CanvasElement => {
28+
if (element.type === 'image') {
29+
const src = map(element.src)
30+
return src === element.src ? element : { ...element, src }
31+
}
32+
if (element.type === 'device' && element.screenshot) {
33+
const screenshot = map(element.screenshot)
34+
return screenshot === element.screenshot ? element : { ...element, screenshot }
35+
}
36+
return element
37+
}
38+
39+
const mapBackgroundSources = (background: Slide['background'], map: (src: string) => string): Slide['background'] => {
40+
if (background.image === undefined) return background
41+
const image = map(background.image)
42+
return image === background.image ? background : { ...background, image }
43+
}
44+
45+
const mapSlideSources = (slide: Slide, map: (src: string) => string): Slide => {
46+
const background = mapBackgroundSources(slide.background, map)
47+
const elements = slide.elements.map((element) => mapElementSources(element, map))
48+
const elementsChanged = elements.some((element, index) => element !== slide.elements[index])
49+
if (background === slide.background && !elementsChanged) return slide
50+
return {
51+
...slide,
52+
background,
53+
elements: elementsChanged ? elements : slide.elements,
54+
}
55+
}
56+
57+
/** Returns a structurally shared copy with every image source passed through `map`. */
58+
export const mapAssetSources = <T extends ProjectAssetSources>(project: T, map: (src: string) => string): T => {
59+
const uploads = project.uploads.map((upload) => {
60+
const src = map(upload.src)
61+
return src === upload.src ? upload : { ...upload, src }
62+
})
63+
const slides = project.slides.map((slide) => mapSlideSources(slide, map))
64+
return { ...project, uploads, slides }
65+
}

0 commit comments

Comments
 (0)