feat(editor): show scene preview immediately and optimize in background

Return control after copying the original asset, then run image optimization
and thumbnail generation in the background with IPC progress updates.
Block duplicate preview uploads and scene creation while requests are in flight.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-07-03 22:38:51 +08:00
parent 83a326b0b9
commit 10bb7013e6
9 changed files with 514 additions and 209 deletions
@@ -28,9 +28,25 @@ void test('zipStore: openProjectById flushes pending saveNow before cache reset'
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
// When switching projects we rm cacheDir and unzip zip; ensure pending debounced pack is flushed first.
assert.match(src, /async openProjectById/);
assert.match(src, /enqueueOpenProject/);
assert.match(src, /openProjectByIdInner/);
assert.match(src, /if \(this\.openProject\)\s*\{\s*await this\.saveNow\(\);\s*\}/);
assert.match(src, /await this\.drainSavePipeline\(\)/);
assert.match(src, /await fs\.rm\(cacheDir, \{ recursive: true, force: true \}\)/);
assert.match(src, /await unzipToDir\(zipPath, cacheDir\)/);
assert.match(src, /await unzipToDir\(zipPath, cacheDir/);
});
void test('zipStore: openProjectById skips re-unzip when project is already open', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /if \(this\.openProject\?\.id === projectId\)\s*\{\s*return this\.openProject\.project;\s*\}/);
});
void test('zipStore: pack and open operations are serialized', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(src, /private packChain: Promise<void>/);
assert.match(src, /private openChain: Promise<void>/);
assert.match(src, /enqueuePack/);
assert.match(src, /enqueueOpenProject/);
});
void test('zipStore: exportProjectZipToPath flushes saveNow for currently open project', () => {
+191 -103
View File
@@ -59,22 +59,44 @@ export class ZipProjectStore {
private projectSession = 0;
/** Serializes project.json writes — parallel renames caused ENOENT on Windows. */
private projectWriteChain: Promise<void> = Promise.resolve();
/** Пока идёт сборка zip, в кэш не пишем — иначе yauzl/yazl: «unexpected number of bytes». */
private isPacking = false;
/** Serializes zip pack operations — parallel yazl/yauzl caused «unexpected number of bytes». */
private packChain: Promise<void> = Promise.resolve();
/** Serializes open/unzip — double-click fired two concurrent opens and corrupted reads. */
private openChain: Promise<void> = Promise.resolve();
private saveDebounceTimer: ReturnType<typeof setTimeout> | null = null;
private async waitWhilePacking(): Promise<void> {
while (this.isPacking) {
await new Promise((r) => setTimeout(r, 15));
}
private enqueuePack(cacheDir: string, zipPath: string): Promise<void> {
const next = this.packChain.then(async () => {
await this.packZipFromCache(cacheDir, zipPath);
});
this.packChain = next.catch(() => undefined);
return next;
}
private async packZipExclusive(cacheDir: string, zipPath: string): Promise<void> {
this.isPacking = true;
try {
await this.packZipFromCache(cacheDir, zipPath);
} finally {
this.isPacking = false;
private enqueueOpenProject(projectId: ProjectId, onUnzipPercent?: (pct: number) => void): Promise<Project> {
const task = this.openChain.then(() => this.openProjectByIdInner(projectId, onUnzipPercent));
this.openChain = task.then(
() => undefined,
() => undefined,
);
return task;
}
/** Waits for debounced save, in-flight pack, and pending project.json writes before reading zip. */
private async drainSavePipeline(): Promise<void> {
if (this.saveDebounceTimer) {
clearTimeout(this.saveDebounceTimer);
this.saveDebounceTimer = null;
}
if (this.saveQueued) {
this.saveQueued = false;
await this.flushSave();
}
while (this.saving) {
await new Promise((r) => setTimeout(r, 10));
}
await this.packChain;
await this.projectWriteChain;
}
async ensureRoots(): Promise<void> {
@@ -190,17 +212,28 @@ export class ZipProjectStore {
const projectPath = path.join(cacheDir, 'project.json');
this.openProject = { id, zipPath, cacheDir, projectPath, project };
await this.writeCacheProject(cacheDir, project);
await this.packZipExclusive(cacheDir, zipPath);
await this.enqueuePack(cacheDir, zipPath);
return this.openProject.project;
}
async openProjectById(projectId: ProjectId): Promise<Project> {
return this.enqueueOpenProject(projectId);
}
private async openProjectByIdInner(
projectId: ProjectId,
onUnzipPercent?: (pct: number) => void,
): Promise<Project> {
await this.ensureRoots();
if (this.openProject?.id === projectId) {
return this.openProject.project;
}
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) {
await this.saveNow();
}
await this.drainSavePipeline();
this.projectSession += 1;
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
@@ -212,7 +245,16 @@ export class ZipProjectStore {
await fs.rm(cacheDir, { recursive: true, force: true });
await fs.mkdir(cacheDir, { recursive: true });
await unzipToDir(zipPath, cacheDir);
try {
await unzipToDir(zipPath, cacheDir, (done, total) => {
if (!onUnzipPercent) return;
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
onUnzipPercent(Math.max(0, Math.min(100, pct)));
});
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`Не удалось открыть проект: архив повреждён или занят (${detail})`);
}
const projectPath = path.join(cacheDir, 'project.json');
const projectRaw = await fs.readFile(projectPath, 'utf8');
@@ -230,38 +272,7 @@ export class ZipProjectStore {
projectId: ProjectId,
onUnzipPercent: (pct: number) => void,
): Promise<Project> {
await this.ensureRoots();
// Mutations are persisted to cache immediately, but zip packing is debounced (queueSave).
// When switching projects we delete the cache and restore it from the zip, so flush pending saves first.
if (this.openProject) {
await this.saveNow();
}
this.projectSession += 1;
const list = await this.listProjects();
const entry = list.find((p) => p.id === projectId);
if (!entry) {
throw new Error('Project not found');
}
const zipPath = path.join(getProjectsRootDir(), entry.fileName);
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
await fs.rm(cacheDir, { recursive: true, force: true });
await fs.mkdir(cacheDir, { recursive: true });
await unzipToDir(zipPath, cacheDir, (done, total) => {
const pct = total > 0 ? Math.round((done / total) * 100) : 0;
onUnzipPercent(Math.max(0, Math.min(100, pct)));
});
const projectPath = path.join(cacheDir, 'project.json');
const projectRaw = await fs.readFile(projectPath, 'utf8');
const parsed = JSON.parse(projectRaw) as unknown as Project;
const project = normalizeProject(parsed);
const fileBaseName = entry.fileName.replace(/\.dnd\.zip$/iu, '');
project.meta.fileBaseName = project.meta.fileBaseName.trim().length
? project.meta.fileBaseName
: fileBaseName;
this.openProject = { id: projectId, zipPath, cacheDir, projectPath, project };
return project;
return this.enqueueOpenProject(projectId, onUnzipPercent);
}
getOpenProject(): Project | null {
@@ -290,7 +301,10 @@ export class ZipProjectStore {
return { absPath: path.join(open.cacheDir, asset.relPath), mime: asset.mime };
}
async importScenePreviewMedia(sceneId: SceneId, filePath: string): Promise<Project> {
async importScenePreviewMedia(
sceneId: SceneId,
filePath: string,
): Promise<{ project: Project; assetId: AssetId; background: boolean }> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const sc = open.project.scenes[sceneId];
@@ -300,54 +314,17 @@ export class ZipProjectStore {
if (!kind0 || (kind0.type !== 'image' && kind0.type !== 'video')) {
throw new Error('Файл превью должен быть изображением или видео');
}
let kind: MediaKind = kind0;
const buf = await fs.readFile(filePath);
const id = asAssetId(this.randomId());
const orig = path.basename(filePath);
let safeOrig = sanitizeFileName(orig);
let relPath = `assets/${id}_${safeOrig}`;
let abs = path.join(open.cacheDir, relPath);
let writeBuf = buf;
let storedOrig = orig;
if (kind.type === 'image') {
const opt = await optimizeImageBufferVisuallyLossless(buf);
if (!opt.passthrough) {
writeBuf = Buffer.from(opt.buffer);
kind = { type: 'image', mime: opt.mime };
safeOrig = sanitizeFileName(`${path.parse(orig).name}.${opt.ext}`);
relPath = `assets/${id}_${safeOrig}`;
abs = path.join(open.cacheDir, relPath);
storedOrig = `${path.parse(orig).name}.${opt.ext}`;
}
}
const sha256 = crypto.createHash('sha256').update(writeBuf).digest('hex');
const safeOrig = sanitizeFileName(orig);
const relPath = `assets/${id}_${safeOrig}`;
const abs = path.join(open.cacheDir, relPath);
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
await fs.mkdir(path.dirname(abs), { recursive: true });
await fs.writeFile(abs, writeBuf);
const asset = buildMediaAsset(id, kind, storedOrig, relPath, sha256, writeBuf.length);
const thumbKind = kind.type === 'image' ? 'image' : 'video';
const thumbBytes = await generateScenePreviewThumbnailBytes(abs, thumbKind);
let thumbAsset: MediaAsset | null = null;
let thumbId: AssetId | null = null;
if (thumbBytes !== null && thumbBytes.length > 0) {
thumbId = asAssetId(this.randomId());
const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`;
const thumbAbs = path.join(open.cacheDir, thumbRelPath);
await fs.writeFile(thumbAbs, thumbBytes);
const thumbSha = crypto.createHash('sha256').update(thumbBytes).digest('hex');
const thumbOrigName = `${path.parse(safeOrig).name}_preview_thumb.webp`;
thumbAsset = buildMediaAsset(
thumbId,
{ type: 'image', mime: 'image/webp' },
thumbOrigName,
thumbRelPath,
thumbSha,
thumbBytes.length,
);
}
await fs.writeFile(abs, buf);
const asset = buildMediaAsset(id, kind0, orig, relPath, sha256, buf.length);
const oldPreviewId = sc.previewAssetId;
const oldThumbId = sc.previewThumbAssetId ?? null;
@@ -365,6 +342,101 @@ export class ZipProjectStore {
) as Record<AssetId, MediaAsset>;
}
assets[id] = asset;
return {
...p,
assets,
scenes: {
...p.scenes,
[sceneId]: {
...scene,
previewAssetId: id,
previewAssetType: kind0.type,
previewThumbAssetId: null,
previewVideoAutostart: kind0.type === 'video' ? scene.previewVideoAutostart : false,
},
},
};
});
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return { project: latest, assetId: id, background: true };
}
async finalizeScenePreviewImport(
sceneId: SceneId,
assetId: AssetId,
): Promise<{ project: Project; changed: boolean }> {
const open = this.openProject;
if (!open) throw new Error('No open project');
const sceneAtStart = open.project.scenes[sceneId];
if (sceneAtStart?.previewAssetId !== assetId) {
return { project: open.project, changed: false };
}
const sourceAsset = open.project.assets[assetId];
if (!sourceAsset || (sourceAsset.type !== 'image' && sourceAsset.type !== 'video')) {
return { project: open.project, changed: false };
}
const generatedRelPaths: string[] = [];
let finalAsset = sourceAsset;
let finalAssetId = assetId;
let finalAbs = path.join(open.cacheDir, sourceAsset.relPath);
if (sourceAsset.type === 'image') {
const input = await fs.readFile(finalAbs);
const opt = await optimizeImageBufferVisuallyLossless(input);
if (!opt.passthrough) {
finalAssetId = asAssetId(this.randomId());
const optimizedName = `${path.parse(sourceAsset.originalName).name}.${opt.ext}`;
const safeOptimizedName = sanitizeFileName(optimizedName);
const optimizedRelPath = `assets/${finalAssetId}_${safeOptimizedName}`;
finalAbs = path.join(open.cacheDir, optimizedRelPath);
const optimizedBuffer = Buffer.from(opt.buffer);
await fs.writeFile(finalAbs, optimizedBuffer);
generatedRelPaths.push(optimizedRelPath);
const optimizedAsset = buildMediaAsset(
finalAssetId,
{ type: 'image', mime: opt.mime },
optimizedName,
optimizedRelPath,
crypto.createHash('sha256').update(optimizedBuffer).digest('hex'),
optimizedBuffer.length,
);
if (optimizedAsset.type !== 'image') {
throw new Error('Optimized preview asset must be an image');
}
finalAsset = optimizedAsset;
}
}
const thumbKind = finalAsset.type === 'image' ? 'image' : 'video';
const thumbBytes = await generateScenePreviewThumbnailBytes(finalAbs, thumbKind);
let thumbAsset: MediaAsset | null = null;
let thumbId: AssetId | null = null;
if (thumbBytes !== null && thumbBytes.length > 0) {
thumbId = asAssetId(this.randomId());
const thumbRelPath = `assets/${thumbId}_preview_thumb.webp`;
const thumbAbs = path.join(open.cacheDir, thumbRelPath);
await fs.writeFile(thumbAbs, thumbBytes);
generatedRelPaths.push(thumbRelPath);
const thumbOrigName = `${path.parse(finalAsset.originalName).name}_preview_thumb.webp`;
thumbAsset = buildMediaAsset(
thumbId,
{ type: 'image', mime: 'image/webp' },
thumbOrigName,
thumbRelPath,
crypto.createHash('sha256').update(thumbBytes).digest('hex'),
thumbBytes.length,
);
}
await this.updateProject((p) => {
const scene = p.scenes[sceneId];
if (scene?.previewAssetId !== assetId) {
return p;
}
const assets: Record<AssetId, MediaAsset> = { ...p.assets, [finalAssetId]: finalAsset };
if (thumbAsset !== null && thumbId !== null) {
assets[thumbId] = thumbAsset;
}
@@ -375,10 +447,10 @@ export class ZipProjectStore {
...p.scenes,
[sceneId]: {
...scene,
previewAssetId: id,
previewAssetType: kind.type,
previewAssetId: finalAssetId,
previewAssetType: finalAsset.type,
previewThumbAssetId: thumbId,
previewVideoAutostart: kind.type === 'video' ? scene.previewVideoAutostart : false,
previewVideoAutostart: finalAsset.type === 'video' ? scene.previewVideoAutostart : false,
},
},
};
@@ -386,7 +458,19 @@ export class ZipProjectStore {
const latest = this.getOpenProject();
if (!latest) throw new Error('No open project');
return latest;
const latestScene = latest.scenes[sceneId];
const applied =
latestScene?.previewAssetId === finalAssetId &&
(thumbId === null || latestScene.previewThumbAssetId === thumbId);
if (!applied) {
await Promise.all(
generatedRelPaths.map((relPath) =>
fs.unlink(path.join(open.cacheDir, relPath)).catch(() => undefined),
),
);
}
return { project: latest, changed: applied };
}
async clearScenePreview(sceneId: SceneId): Promise<Project> {
@@ -759,14 +843,13 @@ export class ZipProjectStore {
const open = this.openProject;
if (!open) return;
await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath);
await this.enqueuePack(open.cacheDir, open.zipPath);
}
async closeOpenProject(): Promise<void> {
if (!this.openProject) return;
await this.saveNow();
await this.waitWhilePacking();
await this.projectWriteChain;
await this.drainSavePipeline();
this.saveQueued = false;
this.openProject = null;
this.projectSession += 1;
@@ -821,7 +904,7 @@ export class ZipProjectStore {
if (nextBase !== oldBase) {
const nextZipPath = path.join(root, nextFileName);
await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath);
await this.enqueuePack(open.cacheDir, open.zipPath);
await replaceFileAtomic(open.zipPath, nextZipPath);
open.zipPath = nextZipPath;
}
@@ -834,7 +917,13 @@ export class ZipProjectStore {
private queueSave() {
if (this.saveQueued) return;
this.saveQueued = true;
setTimeout(() => void this.flushSave(), 250);
if (this.saveDebounceTimer) {
clearTimeout(this.saveDebounceTimer);
}
this.saveDebounceTimer = setTimeout(() => {
this.saveDebounceTimer = null;
void this.flushSave();
}, 250);
}
private async flushSave() {
@@ -846,7 +935,7 @@ export class ZipProjectStore {
this.saving = true;
try {
await this.projectWriteChain;
await this.packZipExclusive(open.cacheDir, open.zipPath);
await this.enqueuePack(open.cacheDir, open.zipPath);
} finally {
this.saving = false;
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- may change during async save
@@ -859,7 +948,7 @@ export class ZipProjectStore {
private async writeCacheProject(cacheDir: string, project: Project): Promise<void> {
const sessionAtStart = this.projectSession;
const run = async (): Promise<void> => {
await this.waitWhilePacking();
await this.packChain;
if (sessionAtStart !== this.projectSession) {
return;
}
@@ -1005,8 +1094,7 @@ export class ZipProjectStore {
const cacheDir = path.join(getProjectsCacheRootDir(), projectId);
if (this.openProject?.id === projectId) {
await this.waitWhilePacking();
await this.projectWriteChain;
await this.drainSavePipeline();
this.saveQueued = false;
this.openProject = null;
this.projectSession += 1;