Files
Genarrative/src/components/image-editor/perfectPixelOperationStore.ts
T
lhk229 8f19964e3f
Project CI / Repository checks (push) Successful in 1m0s
Project CI / Backend tests (push) Successful in 3m52s
Project CI / Native shell tests (push) Successful in 13m8s
Project CI / Frontend tests (push) Successful in 2m43s
加入手动完美像素入口;像素艺术勾选时自动补提示词约束 (#124)
Co-authored-by: 段舒康 <kdletters@qq.com>
Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/124
Reviewed-by: 段舒康 <kdletters@qq.com>
Co-authored-by: Linghong <ink29535@proton.me>
Co-committed-by: Linghong <ink29535@proton.me>
2026-08-05 21:28:40 +08:00

204 lines
7.4 KiB
TypeScript

import { hydratePerfectPixelOperation } from './ImageCanvasEditorModel';
import type { PerfectPixelOperationSnapshot } from './ImageCanvasEditorTypes';
/**
* 中文注释:完美像素操作账本的本机存储。
*
* **这是明确设计,不是降级方案**:账本记录的是「本机这次会话发出过哪一次 POST」,
* 它是对账凭据,不是用户的画布内容,因此不进项目布局。由此得到两条硬性质:
*
* 1. **写入同步、不依赖网络、不依赖服务端校验。** 发 POST 前先落本机账本即可获得
* 「请求可被追溯」的保证,不必再用严格布局保存去换同一个保证。布局校验(例如
* 资源元数据读写不对称)从此不可能阻断完美像素的发起或重试。
* 2. **账本缺失只降级、绝不阻断。** 换设备、换浏览器、清缓存、隐私模式、配额写满,
* 都会读不到账本。那种情况下占位收口为可删除的失败态,用户可以删掉重来;
* 任何路径都不得因为「读不到账本」而拒绝用户发起、重试或删除。
*
* 代价是跨设备不再自动收口:在 A 机发起、到 B 机打开同一项目时,B 机看到的是失败占位
* 而不是对账中的占位。完美像素是免费同步操作,重做成本极低,用这点换掉「用户数据里
* 混着系统对账状态」的耦合是划算的。
*/
const PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX =
'genarrative.imageCanvas.perfectPixelOperations';
/**
* 中文注释:账本保留期。对账窗口只有 75 秒,但 `pending-confirmation` 占位允许用户在很久
* 之后手动重试同一次 operation,那条路径同样需要账本,所以保留期必须远长于对账窗口。
*/
export const PERFECT_PIXEL_OPERATION_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
/**
* 中文注释:单个项目最多保留的账本条数,超出时丢弃最旧的。防止长期使用把 localStorage
* 配额吃满——配额写满会连带影响同域下其它本地缓存,而账本本身是可丢弃的。
*/
export const PERFECT_PIXEL_OPERATION_RETENTION_LIMIT = 32;
type PerfectPixelOperationLedger = Map<string, PerfectPixelOperationSnapshot>;
function getPerfectPixelOperationStorage() {
if (typeof window === 'undefined') {
return null;
}
try {
return window.localStorage;
} catch {
return null;
}
}
function perfectPixelOperationStorageKey(projectId: string | null | undefined) {
const normalizedProjectId = projectId?.trim();
if (!normalizedProjectId) {
return null;
}
return `${PERFECT_PIXEL_OPERATION_STORAGE_KEY_PREFIX}.${normalizedProjectId}`;
}
/**
* 中文注释:读账本时同时校验归属。同一台机器可能先后登录不同账号,账本里带着上一个
* 账号的请求(含源图直传地址),换人后必须整条丢弃而不是原样返回。
*/
function readLedgerEntries(
storage: Storage,
key: string,
ownerUserId: string,
): PerfectPixelOperationLedger {
const ledger: PerfectPixelOperationLedger = new Map();
const rawValue = storage.getItem(key);
if (!rawValue) {
return ledger;
}
const parsedValue: unknown = JSON.parse(rawValue);
if (!parsedValue || typeof parsedValue !== 'object') {
return ledger;
}
const record = parsedValue as Record<string, unknown>;
const recordOwnerUserId =
typeof record.ownerUserId === 'string' ? record.ownerUserId.trim() : '';
if (recordOwnerUserId !== ownerUserId) {
storage.removeItem(key);
return ledger;
}
const operations = record.operations;
if (!operations || typeof operations !== 'object') {
return ledger;
}
const now = Date.now();
for (const [operationId, value] of Object.entries(
operations as Record<string, unknown>,
)) {
// 中文注释:本机账本与布局快照走同一套 v1 白名单校验。存储可被用户或其它脚本改写,
// 任何字段漂移都必须失败关闭——绝不能据一份可疑账本重放 POST。
const operation = hydratePerfectPixelOperation(value, operationId);
if (!operation) {
continue;
}
if (now - operation.submittedAt > PERFECT_PIXEL_OPERATION_RETENTION_MS) {
continue;
}
ledger.set(operationId, operation);
}
return ledger;
}
function writeLedgerEntries(
storage: Storage,
key: string,
ownerUserId: string,
ledger: PerfectPixelOperationLedger,
) {
if (ledger.size === 0) {
storage.removeItem(key);
return;
}
const retained = [...ledger.values()]
.sort((left, right) => right.submittedAt - left.submittedAt)
.slice(0, PERFECT_PIXEL_OPERATION_RETENTION_LIMIT);
storage.setItem(
key,
JSON.stringify({
ownerUserId,
operations: Object.fromEntries(
retained.map((operation) => [operation.operationId, operation]),
),
}),
);
}
/**
* 中文注释:读取某项目在本机的全部有效账本。任何异常都返回空账本——读不到账本只意味着
* 「刷新后不能自动收口」,调用方必须能在空账本下继续工作。
*/
export function readPerfectPixelOperations(
currentUserId: string | null | undefined,
projectId: string | null | undefined,
): PerfectPixelOperationLedger {
const ownerUserId = currentUserId?.trim();
const key = perfectPixelOperationStorageKey(projectId);
const storage = getPerfectPixelOperationStorage();
if (!ownerUserId || !key || !storage) {
return new Map();
}
try {
return readLedgerEntries(storage, key, ownerUserId);
} catch {
return new Map();
}
}
/**
* 中文注释:写入一条账本。必须在发 POST 之前调用——这是整条链路里唯一「请求已发出」的
* 本地证据。写入失败(配额、隐私模式)同样不阻断:调用方照常发 POST,只是丢掉刷新后
* 自动收口的能力。
*/
export function savePerfectPixelOperation(
currentUserId: string | null | undefined,
projectId: string | null | undefined,
operation: PerfectPixelOperationSnapshot,
) {
const ownerUserId = currentUserId?.trim();
const key = perfectPixelOperationStorageKey(projectId);
const storage = getPerfectPixelOperationStorage();
if (!ownerUserId || !key || !storage) {
return;
}
if (operation.request.projectId !== projectId?.trim()) {
return;
}
try {
const ledger = readLedgerEntries(storage, key, ownerUserId);
ledger.set(operation.operationId, operation);
writeLedgerEntries(storage, key, ownerUserId, ledger);
} catch {
// 中文注释:账本是尽力而为的本机便利,写失败不得影响本次提交。
}
}
/**
* 中文注释:操作收口(结果已套用 / 只落素材库 / 快照判定失效)后清账本,避免过期条目
* 在下次加载时再发一次无谓的对账 GET。
*/
export function forgetPerfectPixelOperation(
currentUserId: string | null | undefined,
projectId: string | null | undefined,
operationId: string,
) {
const ownerUserId = currentUserId?.trim();
const key = perfectPixelOperationStorageKey(projectId);
const storage = getPerfectPixelOperationStorage();
const normalizedOperationId = operationId.trim();
if (!ownerUserId || !key || !storage || !normalizedOperationId) {
return;
}
try {
const ledger = readLedgerEntries(storage, key, ownerUserId);
if (!ledger.delete(normalizedOperationId)) {
return;
}
writeLedgerEntries(storage, key, ownerUserId, ledger);
} catch {
// 中文注释:清理失败最多留下一条过期账本,保留期会兜底。
}
}