Improve autosave reliability and implement size limits for local draft storage

This commit is contained in:
2026-04-30 02:56:17 +02:00
parent 3136a47bb7
commit ee7fcce1b3
3 changed files with 129 additions and 23 deletions

View File

@@ -4555,6 +4555,14 @@ export function App({ store, initialStatusMessage }: AppProps) {
autosaveControllerRef.current = new EditorAutosaveController({
saveDraft: () => store.saveDraft(),
onComplete: (result) => {
if (result.status === "skipped") {
if (lastAutosaveErrorRef.current !== result.message) {
lastAutosaveErrorRef.current = result.message;
setStatusMessage(result.message);
}
return;
}
if (result.status === "error") {
if (lastAutosaveErrorRef.current !== result.message) {
lastAutosaveErrorRef.current = result.message;

View File

@@ -14,9 +14,9 @@ import {
import {
parseSceneDocumentJson,
parseProjectDocumentJson,
serializeProjectDocument
parseProjectDocumentJson
} from "./scene-document-json";
import { assertProjectDocumentIsValid } from "../document/scene-document-validation";
export interface KeyValueStorage {
getItem(key: string): string | null;
@@ -31,6 +31,7 @@ export interface BrowserStorageAccessResult {
export type SaveSceneDocumentDraftResult =
| { status: "saved"; message: string }
| { status: "skipped"; message: string }
| { status: "error"; message: string };
export type LoadSceneDocumentDraftResult =
@@ -45,8 +46,15 @@ export interface LoadOrCreateSceneDocumentResult {
}
export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft";
export const DEFAULT_SCENE_DRAFT_MAX_SERIALIZED_BYTES = 4 * 1024 * 1024;
const ESTIMATED_TERRAIN_SAMPLE_JSON_BYTES = 8;
const ESTIMATED_PROJECT_DRAFT_BASE_BYTES = 64 * 1024;
const EDITOR_DRAFT_ENVELOPE_FORMAT = "webeditor3d.editor-draft.v1";
export interface SaveSceneDocumentDraftOptions {
maxSerializedBytes?: number;
}
interface StoredEditorDraftEnvelope {
format: typeof EDITOR_DRAFT_ENVELOPE_FORMAT;
document: unknown;
@@ -65,6 +73,48 @@ function formatStorageDiagnostic(prefix: string, error: unknown): string {
return `${prefix} ${getErrorDetail(error)}`;
}
function formatByteSize(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function getAutosaveTooLargeMessage(sizeBytes: number, maxBytes: number): string {
return `Autosave skipped because this project draft is about ${formatByteSize(sizeBytes)}, above the ${formatByteSize(maxBytes)} browser autosave limit. Use project save/export for terrain-heavy scenes.`;
}
function getTerrainDraftSampleValueCount(document: ProjectDocument): number {
let sampleValueCount = 0;
for (const scene of Object.values(document.scenes)) {
for (const terrain of Object.values(scene.terrains)) {
sampleValueCount += terrain.heights.length + terrain.paintWeights.length;
}
}
return sampleValueCount;
}
export function estimateProjectDraftSerializedBytes(
document: ProjectDocument,
viewportLayoutState: ViewportLayoutState | null = null
): number {
const terrainSampleValueCount = getTerrainDraftSampleValueCount(document);
const viewportBytes = viewportLayoutState === null ? 0 : 16 * 1024;
return (
ESTIMATED_PROJECT_DRAFT_BASE_BYTES +
viewportBytes +
terrainSampleValueCount * ESTIMATED_TERRAIN_SAMPLE_JSON_BYTES
);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
@@ -199,19 +249,56 @@ export function saveSceneDocumentDraft(
storage: KeyValueStorage,
document: ProjectDocument,
viewportLayoutState: ViewportLayoutState | null = null,
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY,
options: SaveSceneDocumentDraftOptions = {}
): SaveSceneDocumentDraftResult {
try {
const rawDocument = serializeProjectDocument(document);
storage.setItem(
key,
JSON.stringify({
format: EDITOR_DRAFT_ENVELOPE_FORMAT,
document: JSON.parse(rawDocument),
viewportLayoutState: viewportLayoutState === null ? null : cloneViewportLayoutState(viewportLayoutState)
} satisfies StoredEditorDraftEnvelope)
assertProjectDocumentIsValid(document);
const maxSerializedBytes =
options.maxSerializedBytes ?? DEFAULT_SCENE_DRAFT_MAX_SERIALIZED_BYTES;
const estimatedDraftBytes = estimateProjectDraftSerializedBytes(
document,
viewportLayoutState
);
if (
Number.isFinite(maxSerializedBytes) &&
maxSerializedBytes > 0 &&
estimatedDraftBytes > maxSerializedBytes
) {
storage.removeItem(key);
return {
status: "skipped",
message: getAutosaveTooLargeMessage(
estimatedDraftBytes,
maxSerializedBytes
)
};
}
const rawDraft = JSON.stringify({
format: EDITOR_DRAFT_ENVELOPE_FORMAT,
document,
viewportLayoutState: viewportLayoutState === null ? null : cloneViewportLayoutState(viewportLayoutState)
} satisfies StoredEditorDraftEnvelope);
if (
Number.isFinite(maxSerializedBytes) &&
maxSerializedBytes > 0 &&
rawDraft.length > maxSerializedBytes
) {
storage.removeItem(key);
return {
status: "skipped",
message: getAutosaveTooLargeMessage(rawDraft.length, maxSerializedBytes)
};
}
storage.setItem(key, rawDraft);
return {
status: "saved",
message: "Autosave updated."

View File

@@ -9531,8 +9531,9 @@ export class ViewportHost {
this.currentTerrainBrushState.tool === "flatten"
? hit.point.y - terrain.position.y
: null;
const previewTerrain = this.applyTerrainBrushPoint(
terrain,
const previewTerrain = cloneTerrain(terrain);
const initialStamp = this.applyTerrainBrushPoint(
previewTerrain,
{
x: hit.point.x,
z: hit.point.z
@@ -9544,6 +9545,7 @@ export class ViewportHost {
this.activeTerrainBrushStroke = {
pointerId: event.pointerId,
previewTerrain,
changed: initialStamp.changed,
referenceHeight,
lastAppliedPoint: {
x: hit.point.x,
@@ -9552,7 +9554,10 @@ export class ViewportHost {
toolState: this.currentTerrainBrushState
};
this.renderer.domElement.setPointerCapture(event.pointerId);
this.rebuildDisplayedTerrainState();
this.refreshDisplayedTerrainDirtyBounds(
previewTerrain.id,
initialStamp.dirtyBounds
);
return true;
}
@@ -9586,10 +9591,7 @@ export class ViewportHost {
);
if (
!areTerrainsEqual(
segmentResult.terrain,
this.activeTerrainBrushStroke.previewTerrain
) ||
segmentResult.changed ||
segmentResult.lastAppliedPoint.x !==
this.activeTerrainBrushStroke.lastAppliedPoint.x ||
segmentResult.lastAppliedPoint.z !==
@@ -9597,10 +9599,17 @@ export class ViewportHost {
) {
this.activeTerrainBrushStroke = {
...this.activeTerrainBrushStroke,
previewTerrain: segmentResult.terrain,
changed:
this.activeTerrainBrushStroke.changed || segmentResult.changed,
lastAppliedPoint: segmentResult.lastAppliedPoint
};
this.rebuildDisplayedTerrainState();
if (segmentResult.changed) {
this.refreshDisplayedTerrainDirtyBounds(
this.activeTerrainBrushStroke.previewTerrain.id,
segmentResult.dirtyBounds
);
}
}
return true;
@@ -9634,6 +9643,7 @@ export class ViewportHost {
const cancelled = event.type === "pointercancel";
let finalPreviewTerrain = this.activeTerrainBrushStroke.previewTerrain;
let changed = this.activeTerrainBrushStroke.changed;
if (!cancelled) {
const hit = this.getTerrainBrushHitAtClientPosition(
@@ -9652,13 +9662,13 @@ export class ViewportHost {
this.activeTerrainBrushStroke.toolState,
this.activeTerrainBrushStroke.referenceHeight
);
finalPreviewTerrain = segmentResult.terrain;
changed ||= segmentResult.changed;
if (
segmentResult.lastAppliedPoint.x !== hit.point.x ||
segmentResult.lastAppliedPoint.z !== hit.point.z
) {
finalPreviewTerrain = this.applyTerrainBrushPoint(
const pointResult = this.applyTerrainBrushPoint(
finalPreviewTerrain,
{
x: hit.point.x,
@@ -9667,6 +9677,7 @@ export class ViewportHost {
this.activeTerrainBrushStroke.toolState,
this.activeTerrainBrushStroke.referenceHeight
);
changed ||= pointResult.changed;
}
}
}
@@ -9678,7 +9689,7 @@ export class ViewportHost {
const commit =
!cancelled &&
baseTerrain !== null &&
!areTerrainsEqual(baseTerrain, finalPreviewTerrain);
changed;
const toolState = this.activeTerrainBrushStroke.toolState;
this.activeTerrainBrushStroke = null;
this.terrainBrushPreviewGroup.visible = false;