@spooky-sync/core 0.0.1-canary.198 → 0.0.1-canary.199
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +59 -3
- package/dist/index.js +152 -8
- package/dist/types.d.ts +16 -0
- package/package.json +4 -3
- package/src/bucket-blurhash.test.ts +148 -0
- package/src/index.ts +11 -0
- package/src/sp00ky.ts +123 -4
- package/src/types.ts +13 -0
- package/src/utils/blurhash.ts +90 -0
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import * as surrealdb0 from "surrealdb";
|
|
|
3
3
|
import { Duration, RecordId, Surreal as Surreal$1, SurrealEvents, SurrealTransaction } from "surrealdb";
|
|
4
4
|
import { AccessDefinition, BackendNames, BackendRoutes, BucketNames, ColumnSchema, FinalQuery, GetTable, QueryBuilder, QueryOptions, QueryPlan, RoutePayload, SchemaStructure, TableModel, TableNames, TypeNameToTypeMap } from "@spooky-sync/query-builder";
|
|
5
5
|
import { Logger } from "pino";
|
|
6
|
+
import { decode, encode, isBlurhashValid } from "blurhash";
|
|
6
7
|
import { LoroDoc } from "loro-crdt";
|
|
7
8
|
|
|
8
9
|
//#region src/services/database/database.d.ts
|
|
@@ -2059,17 +2060,72 @@ declare class BlobCache {
|
|
|
2059
2060
|
stats(): BlobCacheStats;
|
|
2060
2061
|
}
|
|
2061
2062
|
//#endregion
|
|
2063
|
+
//#region src/utils/blurhash.d.ts
|
|
2064
|
+
/**
|
|
2065
|
+
* Blurhash generation settings. `true` enables with the defaults below, `false`
|
|
2066
|
+
* disables. Resolution order for a put: per-call option > client config >
|
|
2067
|
+
* default ON. See {@link Sp00kyConfig.blurhash}.
|
|
2068
|
+
*/
|
|
2069
|
+
type BlurhashSetting = boolean | BlurhashEncodeOptions;
|
|
2070
|
+
interface BlurhashEncodeOptions {
|
|
2071
|
+
/** Horizontal detail components, 1-9. Defaults to 4. */
|
|
2072
|
+
componentX?: number;
|
|
2073
|
+
/** Vertical detail components, 1-9. Defaults to 3. */
|
|
2074
|
+
componentY?: number;
|
|
2075
|
+
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Where an image's blurhash lives: a tiny sidecar object in the same bucket.
|
|
2078
|
+
* Buckets have no per-object metadata channel (`put` is just `.put($content)`),
|
|
2079
|
+
* so the hash for `covers/x_t.webp` is the text object `covers/x_t.webp.bh`.
|
|
2080
|
+
*/
|
|
2081
|
+
declare function blurhashSidecarPath(path: string): string;
|
|
2082
|
+
/** Extensions `bucket.put` treats as images worth hashing. */
|
|
2083
|
+
declare const BLURHASH_IMAGE_EXTENSIONS: readonly ["webp", "png", "jpg", "jpeg", "gif", "avif", "bmp"];
|
|
2084
|
+
declare function isImagePath(path: string): boolean;
|
|
2085
|
+
/**
|
|
2086
|
+
* Decode `content` as an image and compute its blurhash. Browser-only: returns
|
|
2087
|
+
* null (never throws) when image decoding is unavailable (node, workers without
|
|
2088
|
+
* canvas), when the bytes are not a decodable image, or on any other failure —
|
|
2089
|
+
* a missing hash must never break the upload that triggered it.
|
|
2090
|
+
*/
|
|
2091
|
+
declare function encodeImageToBlurhash(content: string | Uint8Array | Blob, options?: BlurhashEncodeOptions): Promise<string | null>;
|
|
2092
|
+
//#endregion
|
|
2062
2093
|
//#region src/sp00ky.d.ts
|
|
2063
2094
|
/** Coerce whatever the `.get()` RPC hands back into a Blob. */
|
|
2064
2095
|
declare function bucketContentToBlob(content: unknown): Blob | null;
|
|
2096
|
+
interface BucketPutOptions {
|
|
2097
|
+
/** Override the client-level {@link Sp00kyConfig.blurhash} setting for this put. */
|
|
2098
|
+
blurhash?: BlurhashSetting;
|
|
2099
|
+
}
|
|
2100
|
+
interface BucketPutResult {
|
|
2101
|
+
/** The computed blurhash when the content was a hashable image; else null. */
|
|
2102
|
+
blurhash: string | null;
|
|
2103
|
+
}
|
|
2104
|
+
interface BucketHandleSettings {
|
|
2105
|
+
blurhash?: BlurhashSetting;
|
|
2106
|
+
logger?: {
|
|
2107
|
+
warn: (obj: unknown, msg?: string) => void;
|
|
2108
|
+
};
|
|
2109
|
+
}
|
|
2065
2110
|
declare class BucketHandle {
|
|
2066
2111
|
private bucketName;
|
|
2067
2112
|
private remote;
|
|
2068
2113
|
/** Absent on the raw handle the cache itself reads through. */
|
|
2069
2114
|
private blobs?;
|
|
2115
|
+
private settings?;
|
|
2070
2116
|
constructor(bucketName: string, remote: RemoteDatabaseService, /** Absent on the raw handle the cache itself reads through. */
|
|
2071
|
-
blobs?: (BlobCache | null) | undefined);
|
|
2072
|
-
|
|
2117
|
+
blobs?: (BlobCache | null) | undefined, settings?: BucketHandleSettings | undefined);
|
|
2118
|
+
/** Effective blurhash setting: per-call option > client config > default ON. */
|
|
2119
|
+
private resolveBlurhash;
|
|
2120
|
+
put(path: string, content: string | Uint8Array | Blob, options?: BucketPutOptions): Promise<BucketPutResult>;
|
|
2121
|
+
/**
|
|
2122
|
+
* The blurhash stored alongside an uploaded image (see
|
|
2123
|
+
* {@link blurhashSidecarPath}), or null when there is none. Reads through the
|
|
2124
|
+
* blob cache, so a warm client answers from OPFS without a network hop, and
|
|
2125
|
+
* misses are remembered per tab so a hashless image costs at most one
|
|
2126
|
+
* serialized remote read per session.
|
|
2127
|
+
*/
|
|
2128
|
+
blurhash(path: string): Promise<string | null>;
|
|
2073
2129
|
get(path: string): Promise<unknown>;
|
|
2074
2130
|
/**
|
|
2075
2131
|
* Read through the local blob cache: OPFS first, the bucket second. Unlike
|
|
@@ -2333,4 +2389,4 @@ declare function textToHtml(text: string): string;
|
|
|
2333
2389
|
*/
|
|
2334
2390
|
|
|
2335
2391
|
//#endregion
|
|
2336
|
-
export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, BucketHandle, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
|
|
2392
|
+
export { AppReleaseHandle, AppReleaseModule, type AppReleaseOptions, type AppReleaseSnapshot, AuthEventSystem, AuthEventTypeMap, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, type BlobCacheStats, type BlobEntry, type BlobKey, type BlobReadOptions, type BlobUrlLease, type BlurhashEncodeOptions, type BlurhashSetting, BucketHandle, BucketPutOptions, BucketPutResult, CURSOR_COLORS, ConnectionState, CrdtField, CrdtManager, DebounceOptions, EventSubscriptionOptions, FeatureFlagHandle, FeatureFlagModule, type FeatureFlagOptions, type FeatureFlagOverride, type FeatureFlagSnapshot, Level, MATERIALIZATION_SAMPLE_WINDOW, MutationCallback, MutationEvent, MutationEventType, PersistenceClient, PhaseStat, PinoTransmit, PreloadOptions, PreloadRefresh, QueryConfig, QueryConfigRecord, QueryHash, QueryState, QueryStatus, QueryStatusCallback, QueryTimeToLive, QueryTimings, QueryUpdateCallback, ReconnectConfig, RecordVersionArray, RecordVersionDiff, RegistrationTimings, RunOptions, Sp00kyClient, Sp00kyConfig, Sp00kyQueryResult, Sp00kyQueryResultPromise, StorageHealth, StorageHealthStatus, StoreType, SyncHealth, SyncHealthConfig, SyncHealthStatus, TimingPhase, UpdateOptions, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, isBlurhashValid, isImagePath, semverGt, textToHtml };
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { QueryBuilder, RecordId as RecordId$1, cyrb53 } from "@spooky-sync/query
|
|
|
4
4
|
import pino from "pino";
|
|
5
5
|
import { applyPatch } from "fast-json-patch";
|
|
6
6
|
import init, { Sp00kyProcessor } from "@spooky-sync/ssp-wasm";
|
|
7
|
+
import { decode, encode, isBlurhashValid } from "blurhash";
|
|
7
8
|
|
|
8
9
|
//#region src/types.ts
|
|
9
10
|
/** Cap on the rolling materialization-sample window kept per query in memory. */
|
|
@@ -7092,8 +7093,8 @@ function selfAllowlistedVariant(flag, userId) {
|
|
|
7092
7093
|
|
|
7093
7094
|
//#endregion
|
|
7094
7095
|
//#region src/modules/devtools/index.ts
|
|
7095
|
-
const CORE_VERSION = "0.0.1-canary.
|
|
7096
|
-
const WASM_VERSION = "0.0.1-canary.
|
|
7096
|
+
const CORE_VERSION = "0.0.1-canary.199";
|
|
7097
|
+
const WASM_VERSION = "0.0.1-canary.199";
|
|
7097
7098
|
const SURREAL_VERSION = "3.0.3";
|
|
7098
7099
|
var DevToolsService = class DevToolsService {
|
|
7099
7100
|
eventsHistory = [];
|
|
@@ -11222,6 +11223,65 @@ function createBlobCache(opts) {
|
|
|
11222
11223
|
});
|
|
11223
11224
|
}
|
|
11224
11225
|
|
|
11226
|
+
//#endregion
|
|
11227
|
+
//#region src/utils/blurhash.ts
|
|
11228
|
+
/**
|
|
11229
|
+
* Where an image's blurhash lives: a tiny sidecar object in the same bucket.
|
|
11230
|
+
* Buckets have no per-object metadata channel (`put` is just `.put($content)`),
|
|
11231
|
+
* so the hash for `covers/x_t.webp` is the text object `covers/x_t.webp.bh`.
|
|
11232
|
+
*/
|
|
11233
|
+
function blurhashSidecarPath(path) {
|
|
11234
|
+
return `${path}.bh`;
|
|
11235
|
+
}
|
|
11236
|
+
/** Extensions `bucket.put` treats as images worth hashing. */
|
|
11237
|
+
const BLURHASH_IMAGE_EXTENSIONS = [
|
|
11238
|
+
"webp",
|
|
11239
|
+
"png",
|
|
11240
|
+
"jpg",
|
|
11241
|
+
"jpeg",
|
|
11242
|
+
"gif",
|
|
11243
|
+
"avif",
|
|
11244
|
+
"bmp"
|
|
11245
|
+
];
|
|
11246
|
+
const IMAGE_EXT_RE = new RegExp(`\\.(${BLURHASH_IMAGE_EXTENSIONS.join("|")})$`, "i");
|
|
11247
|
+
function isImagePath(path) {
|
|
11248
|
+
return IMAGE_EXT_RE.test(path);
|
|
11249
|
+
}
|
|
11250
|
+
/** Longest edge of the downscale the hash is computed from. Blurhash carries at
|
|
11251
|
+
* most 9x9 DCT components, so anything past ~32px is wasted decode work. */
|
|
11252
|
+
const ENCODE_MAX_EDGE = 32;
|
|
11253
|
+
/**
|
|
11254
|
+
* Decode `content` as an image and compute its blurhash. Browser-only: returns
|
|
11255
|
+
* null (never throws) when image decoding is unavailable (node, workers without
|
|
11256
|
+
* canvas), when the bytes are not a decodable image, or on any other failure —
|
|
11257
|
+
* a missing hash must never break the upload that triggered it.
|
|
11258
|
+
*/
|
|
11259
|
+
async function encodeImageToBlurhash(content, options) {
|
|
11260
|
+
if (typeof createImageBitmap !== "function") return null;
|
|
11261
|
+
let bitmap = null;
|
|
11262
|
+
try {
|
|
11263
|
+
const blob = content instanceof Blob ? content : new Blob([content]);
|
|
11264
|
+
bitmap = await createImageBitmap(blob);
|
|
11265
|
+
const scale = Math.min(1, ENCODE_MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
|
11266
|
+
const width = Math.max(1, Math.round(bitmap.width * scale));
|
|
11267
|
+
const height = Math.max(1, Math.round(bitmap.height * scale));
|
|
11268
|
+
const canvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(width, height) : typeof document !== "undefined" ? Object.assign(document.createElement("canvas"), {
|
|
11269
|
+
width,
|
|
11270
|
+
height
|
|
11271
|
+
}) : null;
|
|
11272
|
+
if (!canvas) return null;
|
|
11273
|
+
const ctx = canvas.getContext("2d");
|
|
11274
|
+
if (!ctx) return null;
|
|
11275
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
11276
|
+
const { data } = ctx.getImageData(0, 0, width, height);
|
|
11277
|
+
return encode(data, width, height, options?.componentX ?? 4, options?.componentY ?? 3);
|
|
11278
|
+
} catch {
|
|
11279
|
+
return null;
|
|
11280
|
+
} finally {
|
|
11281
|
+
bitmap?.close();
|
|
11282
|
+
}
|
|
11283
|
+
}
|
|
11284
|
+
|
|
11225
11285
|
//#endregion
|
|
11226
11286
|
//#region src/sp00ky.ts
|
|
11227
11287
|
/** Coerce whatever the `.get()` RPC hands back into a Blob. */
|
|
@@ -11233,18 +11293,85 @@ function bucketContentToBlob(content) {
|
|
|
11233
11293
|
if (ArrayBuffer.isView(content)) return new Blob([content]);
|
|
11234
11294
|
return null;
|
|
11235
11295
|
}
|
|
11296
|
+
/**
|
|
11297
|
+
* Paths known to have no blurhash sidecar, per tab. The blob cache has no
|
|
11298
|
+
* negative caching, so without this every mount of a hashless image would pay
|
|
11299
|
+
* one serialized remote read. Cleared when a put writes a sidecar or a delete
|
|
11300
|
+
* removes the image. Keyed `${bucket}:${path}` (the image path, not the sidecar).
|
|
11301
|
+
*/
|
|
11302
|
+
const missingBlurhash = /* @__PURE__ */ new Set();
|
|
11236
11303
|
var BucketHandle = class {
|
|
11237
|
-
constructor(bucketName, remote, blobs) {
|
|
11304
|
+
constructor(bucketName, remote, blobs, settings) {
|
|
11238
11305
|
this.bucketName = bucketName;
|
|
11239
11306
|
this.remote = remote;
|
|
11240
11307
|
this.blobs = blobs;
|
|
11308
|
+
this.settings = settings;
|
|
11241
11309
|
}
|
|
11242
|
-
|
|
11310
|
+
/** Effective blurhash setting: per-call option > client config > default ON. */
|
|
11311
|
+
resolveBlurhash(option) {
|
|
11312
|
+
const setting = option ?? this.settings?.blurhash ?? true;
|
|
11313
|
+
if (setting === false) return null;
|
|
11314
|
+
return setting === true ? {} : setting;
|
|
11315
|
+
}
|
|
11316
|
+
async put(path, content, options) {
|
|
11317
|
+
const encodeOptions = this.resolveBlurhash(options?.blurhash);
|
|
11318
|
+
const hashPromise = encodeOptions && isImagePath(path) ? encodeImageToBlurhash(content, encodeOptions) : Promise.resolve(null);
|
|
11243
11319
|
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
|
|
11244
11320
|
await this.blobs?.invalidate({
|
|
11245
11321
|
bucket: this.bucketName,
|
|
11246
11322
|
path
|
|
11247
11323
|
});
|
|
11324
|
+
let hash = null;
|
|
11325
|
+
try {
|
|
11326
|
+
hash = await hashPromise;
|
|
11327
|
+
if (hash) {
|
|
11328
|
+
const sidecar = blurhashSidecarPath(path);
|
|
11329
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".put($content);`, { content: hash });
|
|
11330
|
+
await this.blobs?.invalidate({
|
|
11331
|
+
bucket: this.bucketName,
|
|
11332
|
+
path: sidecar
|
|
11333
|
+
});
|
|
11334
|
+
missingBlurhash.delete(`${this.bucketName}:${path}`);
|
|
11335
|
+
}
|
|
11336
|
+
} catch (error) {
|
|
11337
|
+
hash = null;
|
|
11338
|
+
this.settings?.logger?.warn({
|
|
11339
|
+
error,
|
|
11340
|
+
path
|
|
11341
|
+
}, "blurhash sidecar put failed");
|
|
11342
|
+
}
|
|
11343
|
+
return { blurhash: hash };
|
|
11344
|
+
}
|
|
11345
|
+
/**
|
|
11346
|
+
* The blurhash stored alongside an uploaded image (see
|
|
11347
|
+
* {@link blurhashSidecarPath}), or null when there is none. Reads through the
|
|
11348
|
+
* blob cache, so a warm client answers from OPFS without a network hop, and
|
|
11349
|
+
* misses are remembered per tab so a hashless image costs at most one
|
|
11350
|
+
* serialized remote read per session.
|
|
11351
|
+
*/
|
|
11352
|
+
async blurhash(path) {
|
|
11353
|
+
const cacheKey = `${this.bucketName}:${path}`;
|
|
11354
|
+
if (missingBlurhash.has(cacheKey)) return null;
|
|
11355
|
+
try {
|
|
11356
|
+
const blob = await this.read(blurhashSidecarPath(path), { persist: true });
|
|
11357
|
+
if (!blob) {
|
|
11358
|
+
missingBlurhash.add(cacheKey);
|
|
11359
|
+
return null;
|
|
11360
|
+
}
|
|
11361
|
+
const hash = (await blob.text()).trim();
|
|
11362
|
+
if (!isBlurhashValid(hash).result) {
|
|
11363
|
+
this.settings?.logger?.warn({ path }, "blurhash sidecar holds an invalid hash");
|
|
11364
|
+
missingBlurhash.add(cacheKey);
|
|
11365
|
+
return null;
|
|
11366
|
+
}
|
|
11367
|
+
return hash;
|
|
11368
|
+
} catch (error) {
|
|
11369
|
+
this.settings?.logger?.warn({
|
|
11370
|
+
error,
|
|
11371
|
+
path
|
|
11372
|
+
}, "blurhash sidecar read failed");
|
|
11373
|
+
return null;
|
|
11374
|
+
}
|
|
11248
11375
|
}
|
|
11249
11376
|
async get(path) {
|
|
11250
11377
|
const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".get();`);
|
|
@@ -11307,6 +11434,17 @@ var BucketHandle = class {
|
|
|
11307
11434
|
bucket: this.bucketName,
|
|
11308
11435
|
path
|
|
11309
11436
|
});
|
|
11437
|
+
if (isImagePath(path)) {
|
|
11438
|
+
const sidecar = blurhashSidecarPath(path);
|
|
11439
|
+
try {
|
|
11440
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".delete();`);
|
|
11441
|
+
await this.blobs?.invalidate({
|
|
11442
|
+
bucket: this.bucketName,
|
|
11443
|
+
path: sidecar
|
|
11444
|
+
});
|
|
11445
|
+
} catch {}
|
|
11446
|
+
missingBlurhash.delete(`${this.bucketName}:${path}`);
|
|
11447
|
+
}
|
|
11310
11448
|
}
|
|
11311
11449
|
async exists(path) {
|
|
11312
11450
|
const [result] = await this.remote.query(`RETURN f"${this.bucketName}:/${path}".exists();`);
|
|
@@ -11524,7 +11662,7 @@ var Sp00kyClient = class {
|
|
|
11524
11662
|
return new TabsCoordinator({
|
|
11525
11663
|
tabId,
|
|
11526
11664
|
fingerprint: computeTabsFingerprint({
|
|
11527
|
-
coreVersion: "0.0.1-canary.
|
|
11665
|
+
coreVersion: "0.0.1-canary.199",
|
|
11528
11666
|
schemaHash: hash53(this.config.schemaSurql),
|
|
11529
11667
|
endpoint: this.config.database.endpoint ?? "",
|
|
11530
11668
|
namespace: this.config.database.namespace,
|
|
@@ -12074,12 +12212,18 @@ var Sp00kyClient = class {
|
|
|
12074
12212
|
return this.dataModule.run(backend, path, payload, options);
|
|
12075
12213
|
}
|
|
12076
12214
|
bucket(name) {
|
|
12077
|
-
return new BucketHandle(name, this.remote, this.blobs
|
|
12215
|
+
return new BucketHandle(name, this.remote, this.blobs, {
|
|
12216
|
+
blurhash: this.config.blurhash,
|
|
12217
|
+
logger: this.logger
|
|
12218
|
+
});
|
|
12078
12219
|
}
|
|
12079
12220
|
/** Cache-free handle. The blob cache reads the remote through this, so a
|
|
12080
12221
|
* cache miss can't loop back into the cache. */
|
|
12081
12222
|
rawBucket(name) {
|
|
12082
|
-
return new BucketHandle(name, this.remote, null
|
|
12223
|
+
return new BucketHandle(name, this.remote, null, {
|
|
12224
|
+
blurhash: this.config.blurhash,
|
|
12225
|
+
logger: this.logger
|
|
12226
|
+
});
|
|
12083
12227
|
}
|
|
12084
12228
|
/** Blob cache counters for DevTools. */
|
|
12085
12229
|
getBlobCacheStats() {
|
|
@@ -12118,4 +12262,4 @@ var Sp00kyClient = class {
|
|
|
12118
12262
|
};
|
|
12119
12263
|
|
|
12120
12264
|
//#endregion
|
|
12121
|
-
export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, fileToUint8Array, semverGt, textToHtml };
|
|
12265
|
+
export { AppReleaseHandle, AppReleaseModule, AuthEventTypes, AuthService, BLURHASH_IMAGE_EXTENSIONS, BucketHandle, CURSOR_COLORS, CrdtField, CrdtManager, FeatureFlagHandle, FeatureFlagModule, MATERIALIZATION_SAMPLE_WINDOW, Sp00kyClient, blurhashSidecarPath, bucketContentToBlob, createAuthEventSystem, cursorColorFromName, decode as decodeBlurhash, encode as encodeBlurhash, encodeImageToBlurhash, fileToUint8Array, isBlurhashValid, isImagePath, semverGt, textToHtml };
|
package/dist/types.d.ts
CHANGED
|
@@ -629,6 +629,22 @@ interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
629
629
|
* (or `degradeAfterConsecutiveFailures: 0`) to never report degraded.
|
|
630
630
|
*/
|
|
631
631
|
syncHealth?: SyncHealthConfig | false;
|
|
632
|
+
/**
|
|
633
|
+
* Automatic blurhash placeholders for bucket image uploads. On every
|
|
634
|
+
* `bucket.put` of an image path (by extension: webp/png/jpg/jpeg/gif/avif/bmp)
|
|
635
|
+
* the client computes a blurhash and stores it as a tiny sidecar object
|
|
636
|
+
* `<path>.bh` in the same bucket, best-effort. Read it back with
|
|
637
|
+
* `bucket.blurhash(path)` (or the client-solid `useBucketImage`/`BucketImage`
|
|
638
|
+
* helpers) to paint a placeholder until the image is decoded.
|
|
639
|
+
*
|
|
640
|
+
* `true` (the default) enables with 4x3 components; pass
|
|
641
|
+
* `{ componentX, componentY }` to tune detail, or `false` to disable.
|
|
642
|
+
* A per-call `put(path, content, { blurhash })` option overrides this.
|
|
643
|
+
*/
|
|
644
|
+
blurhash?: boolean | {
|
|
645
|
+
componentX?: number;
|
|
646
|
+
componentY?: number;
|
|
647
|
+
};
|
|
632
648
|
/**
|
|
633
649
|
* Deadline (ms) for a single outgoing mutation push. Tighter than
|
|
634
650
|
* {@link Sp00kyConfig.database.queryTimeoutMs} because the up-queue drains
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spooky-sync/core",
|
|
3
|
-
"version": "0.0.1-canary.
|
|
3
|
+
"version": "0.0.1-canary.199",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -60,10 +60,11 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@spooky-sync/query-builder": "0.0.1-canary.
|
|
64
|
-
"@spooky-sync/ssp-wasm": "0.0.1-canary.
|
|
63
|
+
"@spooky-sync/query-builder": "0.0.1-canary.199",
|
|
64
|
+
"@spooky-sync/ssp-wasm": "0.0.1-canary.199",
|
|
65
65
|
"@sqlite.org/sqlite-wasm": "3.53.0-build1",
|
|
66
66
|
"@surrealdb/wasm": "^3.0.3",
|
|
67
|
+
"blurhash": "^2.0.5",
|
|
67
68
|
"fast-json-patch": "^3.1.1",
|
|
68
69
|
"loro-crdt": "^1.5.6",
|
|
69
70
|
"pino": "^10.1.0",
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { BucketHandle } from './sp00ky';
|
|
3
|
+
import type { RemoteDatabaseService } from './services/database/index';
|
|
4
|
+
import {
|
|
5
|
+
blurhashSidecarPath,
|
|
6
|
+
isImagePath,
|
|
7
|
+
encodeImageToBlurhash,
|
|
8
|
+
encodeBlurhash,
|
|
9
|
+
} from './utils/blurhash';
|
|
10
|
+
|
|
11
|
+
// A real 1x1 hash so isBlurhashValid passes in the read-path tests.
|
|
12
|
+
const VALID_HASH = encodeBlurhash(new Uint8ClampedArray([12, 34, 56, 255]), 1, 1, 1, 1);
|
|
13
|
+
|
|
14
|
+
function makeRemote(queries: string[]) {
|
|
15
|
+
return {
|
|
16
|
+
query: vi.fn(async (surql: string) => {
|
|
17
|
+
queries.push(surql);
|
|
18
|
+
return [null];
|
|
19
|
+
}),
|
|
20
|
+
} as unknown as RemoteDatabaseService;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('blurhash path helpers', () => {
|
|
24
|
+
it('appends .bh for the sidecar', () => {
|
|
25
|
+
expect(blurhashSidecarPath('a/b_t.webp')).toBe('a/b_t.webp.bh');
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('recognizes image extensions case-insensitively', () => {
|
|
29
|
+
expect(isImagePath('x/y.webp')).toBe(true);
|
|
30
|
+
expect(isImagePath('x/y.PNG')).toBe(true);
|
|
31
|
+
expect(isImagePath('x/y.webp.bh')).toBe(false);
|
|
32
|
+
expect(isImagePath('x/notes.txt')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe('encodeImageToBlurhash outside a browser', () => {
|
|
37
|
+
it('returns null instead of throwing (no createImageBitmap in node)', async () => {
|
|
38
|
+
await expect(encodeImageToBlurhash(new Uint8Array([1, 2, 3]))).resolves.toBeNull();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('BucketHandle.put', () => {
|
|
43
|
+
let queries: string[];
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
queries = [];
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('image put succeeds with blurhash null when encoding is unavailable', async () => {
|
|
50
|
+
const handle = new BucketHandle('covers', makeRemote(queries), null);
|
|
51
|
+
const result = await handle.put('a/b_t.webp', new Uint8Array([1]));
|
|
52
|
+
expect(result).toEqual({ blurhash: null });
|
|
53
|
+
// Only the image put went out; no sidecar for a failed/unavailable encode.
|
|
54
|
+
expect(queries).toHaveLength(1);
|
|
55
|
+
expect(queries[0]).toContain('covers:/a/b_t.webp".put');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('skips hashing entirely for non-image paths and when disabled', async () => {
|
|
59
|
+
const handle = new BucketHandle('covers', makeRemote(queries), null, { blurhash: false });
|
|
60
|
+
await handle.put('a/b_t.webp', new Uint8Array([1]));
|
|
61
|
+
await handle.put('a/readme.txt', 'hello');
|
|
62
|
+
expect(queries).toHaveLength(2);
|
|
63
|
+
expect(queries.some((q) => q.includes('.bh'))).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('writes the sidecar when a hash is produced', async () => {
|
|
67
|
+
// Simulate a browser: a createImageBitmap whose bitmap a canvas can read.
|
|
68
|
+
const pixels = { width: 2, height: 2 };
|
|
69
|
+
vi.stubGlobal(
|
|
70
|
+
'createImageBitmap',
|
|
71
|
+
vi.fn(async () => ({ ...pixels, close: vi.fn() }))
|
|
72
|
+
);
|
|
73
|
+
vi.stubGlobal(
|
|
74
|
+
'OffscreenCanvas',
|
|
75
|
+
class {
|
|
76
|
+
width: number;
|
|
77
|
+
height: number;
|
|
78
|
+
constructor(w: number, h: number) {
|
|
79
|
+
this.width = w;
|
|
80
|
+
this.height = h;
|
|
81
|
+
}
|
|
82
|
+
getContext() {
|
|
83
|
+
return {
|
|
84
|
+
drawImage: () => {},
|
|
85
|
+
getImageData: (_x: number, _y: number, w: number, h: number) => ({
|
|
86
|
+
data: new Uint8ClampedArray(w * h * 4).fill(128),
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
);
|
|
92
|
+
try {
|
|
93
|
+
const handle = new BucketHandle('covers', makeRemote(queries), null);
|
|
94
|
+
const result = await handle.put('a/b_t.webp', new Uint8Array([1]));
|
|
95
|
+
expect(result.blurhash).toBeTruthy();
|
|
96
|
+
expect(queries).toHaveLength(2);
|
|
97
|
+
expect(queries[1]).toContain('covers:/a/b_t.webp.bh".put');
|
|
98
|
+
} finally {
|
|
99
|
+
vi.unstubAllGlobals();
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('BucketHandle.blurhash', () => {
|
|
105
|
+
it('returns a valid stored hash and negative-caches misses per path', async () => {
|
|
106
|
+
const reads: string[] = [];
|
|
107
|
+
let payload: unknown = VALID_HASH;
|
|
108
|
+
const remote = {
|
|
109
|
+
query: vi.fn(async (surql: string) => {
|
|
110
|
+
reads.push(surql);
|
|
111
|
+
return [payload];
|
|
112
|
+
}),
|
|
113
|
+
} as unknown as RemoteDatabaseService;
|
|
114
|
+
const handle = new BucketHandle('covers', remote, null);
|
|
115
|
+
|
|
116
|
+
await expect(handle.blurhash('hit/x.webp')).resolves.toBe(VALID_HASH);
|
|
117
|
+
|
|
118
|
+
payload = null;
|
|
119
|
+
await expect(handle.blurhash('miss/x.webp')).resolves.toBeNull();
|
|
120
|
+
const readsAfterMiss = reads.length;
|
|
121
|
+
// The miss is remembered: no second remote read for the same path.
|
|
122
|
+
await expect(handle.blurhash('miss/x.webp')).resolves.toBeNull();
|
|
123
|
+
expect(reads).toHaveLength(readsAfterMiss);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('treats invalid sidecar content as missing', async () => {
|
|
127
|
+
const remote = {
|
|
128
|
+
query: vi.fn(async () => ['not a blurhash \\ at all']),
|
|
129
|
+
} as unknown as RemoteDatabaseService;
|
|
130
|
+
const handle = new BucketHandle('covers', remote, null);
|
|
131
|
+
await expect(handle.blurhash('bad/x.webp')).resolves.toBeNull();
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('BucketHandle.delete', () => {
|
|
136
|
+
it('also deletes the sidecar for image paths, best-effort', async () => {
|
|
137
|
+
const queries: string[] = [];
|
|
138
|
+
const handle = new BucketHandle('covers', makeRemote(queries), null);
|
|
139
|
+
await handle.delete('a/b_t.webp');
|
|
140
|
+
expect(queries).toHaveLength(2);
|
|
141
|
+
expect(queries[0]).toContain('covers:/a/b_t.webp".delete');
|
|
142
|
+
expect(queries[1]).toContain('covers:/a/b_t.webp.bh".delete');
|
|
143
|
+
|
|
144
|
+
queries.length = 0;
|
|
145
|
+
await handle.delete('a/notes.txt');
|
|
146
|
+
expect(queries).toHaveLength(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -24,3 +24,14 @@ export type {
|
|
|
24
24
|
} from './services/blobs/index';
|
|
25
25
|
export { semverGt } from './utils/semver';
|
|
26
26
|
export { fileToUint8Array, textToHtml } from './utils/index';
|
|
27
|
+
export {
|
|
28
|
+
blurhashSidecarPath,
|
|
29
|
+
encodeImageToBlurhash,
|
|
30
|
+
encodeBlurhash,
|
|
31
|
+
decodeBlurhash,
|
|
32
|
+
isBlurhashValid,
|
|
33
|
+
isImagePath,
|
|
34
|
+
BLURHASH_IMAGE_EXTENSIONS,
|
|
35
|
+
type BlurhashSetting,
|
|
36
|
+
type BlurhashEncodeOptions,
|
|
37
|
+
} from './utils/blurhash';
|
package/src/sp00ky.ts
CHANGED
|
@@ -62,6 +62,15 @@ import type { SqliteCacheEngine } from './services/database/sqlite-cache-engine'
|
|
|
62
62
|
import type { BlobCache, BlobReadOptions, BlobUrlLease } from './services/blobs/index';
|
|
63
63
|
import { MemoryBlobStore, createBlobCache, resolveBlobBudget } from './services/blobs/index';
|
|
64
64
|
|
|
65
|
+
import {
|
|
66
|
+
blurhashSidecarPath,
|
|
67
|
+
encodeImageToBlurhash,
|
|
68
|
+
isImagePath,
|
|
69
|
+
isBlurhashValid,
|
|
70
|
+
type BlurhashSetting,
|
|
71
|
+
type BlurhashEncodeOptions,
|
|
72
|
+
} from './utils/blurhash';
|
|
73
|
+
|
|
65
74
|
/** Coerce whatever the `.get()` RPC hands back into a Blob. */
|
|
66
75
|
export function bucketContentToBlob(content: unknown): Blob | null {
|
|
67
76
|
if (content == null) return null;
|
|
@@ -74,18 +83,110 @@ export function bucketContentToBlob(content: unknown): Blob | null {
|
|
|
74
83
|
return null;
|
|
75
84
|
}
|
|
76
85
|
|
|
86
|
+
export interface BucketPutOptions {
|
|
87
|
+
/** Override the client-level {@link Sp00kyConfig.blurhash} setting for this put. */
|
|
88
|
+
blurhash?: BlurhashSetting;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface BucketPutResult {
|
|
92
|
+
/** The computed blurhash when the content was a hashable image; else null. */
|
|
93
|
+
blurhash: string | null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface BucketHandleSettings {
|
|
97
|
+
blurhash?: BlurhashSetting;
|
|
98
|
+
logger?: { warn: (obj: unknown, msg?: string) => void };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Paths known to have no blurhash sidecar, per tab. The blob cache has no
|
|
103
|
+
* negative caching, so without this every mount of a hashless image would pay
|
|
104
|
+
* one serialized remote read. Cleared when a put writes a sidecar or a delete
|
|
105
|
+
* removes the image. Keyed `${bucket}:${path}` (the image path, not the sidecar).
|
|
106
|
+
*/
|
|
107
|
+
const missingBlurhash = new Set<string>();
|
|
108
|
+
|
|
77
109
|
export class BucketHandle {
|
|
78
110
|
constructor(
|
|
79
111
|
private bucketName: string,
|
|
80
112
|
private remote: RemoteDatabaseService,
|
|
81
113
|
/** Absent on the raw handle the cache itself reads through. */
|
|
82
|
-
private blobs?: BlobCache | null
|
|
114
|
+
private blobs?: BlobCache | null,
|
|
115
|
+
private settings?: BucketHandleSettings
|
|
83
116
|
) {}
|
|
84
117
|
|
|
85
|
-
|
|
118
|
+
/** Effective blurhash setting: per-call option > client config > default ON. */
|
|
119
|
+
private resolveBlurhash(option?: BlurhashSetting): BlurhashEncodeOptions | null {
|
|
120
|
+
const setting = option ?? this.settings?.blurhash ?? true;
|
|
121
|
+
if (setting === false) return null;
|
|
122
|
+
return setting === true ? {} : setting;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async put(
|
|
126
|
+
path: string,
|
|
127
|
+
content: string | Uint8Array | Blob,
|
|
128
|
+
options?: BucketPutOptions
|
|
129
|
+
): Promise<BucketPutResult> {
|
|
130
|
+
// Start hashing while the upload is in flight; both browser-side costs
|
|
131
|
+
// overlap and the sidecar put only queues once the main put resolved.
|
|
132
|
+
const encodeOptions = this.resolveBlurhash(options?.blurhash);
|
|
133
|
+
const hashPromise =
|
|
134
|
+
encodeOptions && isImagePath(path)
|
|
135
|
+
? encodeImageToBlurhash(content, encodeOptions)
|
|
136
|
+
: Promise.resolve(null);
|
|
137
|
+
|
|
86
138
|
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".put($content);`, { content });
|
|
87
139
|
// A path can be overwritten, so anything cached under it is now wrong.
|
|
88
140
|
await this.blobs?.invalidate({ bucket: this.bucketName, path });
|
|
141
|
+
|
|
142
|
+
// The sidecar is best-effort: a hash or sidecar failure must never fail
|
|
143
|
+
// the image put that triggered it.
|
|
144
|
+
let hash: string | null = null;
|
|
145
|
+
try {
|
|
146
|
+
hash = await hashPromise;
|
|
147
|
+
if (hash) {
|
|
148
|
+
const sidecar = blurhashSidecarPath(path);
|
|
149
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".put($content);`, {
|
|
150
|
+
content: hash,
|
|
151
|
+
});
|
|
152
|
+
await this.blobs?.invalidate({ bucket: this.bucketName, path: sidecar });
|
|
153
|
+
missingBlurhash.delete(`${this.bucketName}:${path}`);
|
|
154
|
+
}
|
|
155
|
+
} catch (error) {
|
|
156
|
+
hash = null;
|
|
157
|
+
this.settings?.logger?.warn({ error, path }, 'blurhash sidecar put failed');
|
|
158
|
+
}
|
|
159
|
+
return { blurhash: hash };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The blurhash stored alongside an uploaded image (see
|
|
164
|
+
* {@link blurhashSidecarPath}), or null when there is none. Reads through the
|
|
165
|
+
* blob cache, so a warm client answers from OPFS without a network hop, and
|
|
166
|
+
* misses are remembered per tab so a hashless image costs at most one
|
|
167
|
+
* serialized remote read per session.
|
|
168
|
+
*/
|
|
169
|
+
async blurhash(path: string): Promise<string | null> {
|
|
170
|
+
const cacheKey = `${this.bucketName}:${path}`;
|
|
171
|
+
if (missingBlurhash.has(cacheKey)) return null;
|
|
172
|
+
try {
|
|
173
|
+
const blob = await this.read(blurhashSidecarPath(path), { persist: true });
|
|
174
|
+
if (!blob) {
|
|
175
|
+
missingBlurhash.add(cacheKey);
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const hash = (await blob.text()).trim();
|
|
179
|
+
if (!isBlurhashValid(hash).result) {
|
|
180
|
+
this.settings?.logger?.warn({ path }, 'blurhash sidecar holds an invalid hash');
|
|
181
|
+
missingBlurhash.add(cacheKey);
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return hash;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// Transient failure: do NOT negative-cache, the next mount may succeed.
|
|
187
|
+
this.settings?.logger?.warn({ error, path }, 'blurhash sidecar read failed');
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
89
190
|
}
|
|
90
191
|
|
|
91
192
|
async get(path: string): Promise<unknown> {
|
|
@@ -137,6 +238,18 @@ export class BucketHandle {
|
|
|
137
238
|
async delete(path: string): Promise<void> {
|
|
138
239
|
await this.remote.query(`RETURN f"${this.bucketName}:/${path}".delete();`);
|
|
139
240
|
await this.blobs?.invalidate({ bucket: this.bucketName, path });
|
|
241
|
+
// Symmetry with put: an image's blurhash sidecar dies with it. Best-effort,
|
|
242
|
+
// the sidecar may simply not exist.
|
|
243
|
+
if (isImagePath(path)) {
|
|
244
|
+
const sidecar = blurhashSidecarPath(path);
|
|
245
|
+
try {
|
|
246
|
+
await this.remote.query(`RETURN f"${this.bucketName}:/${sidecar}".delete();`);
|
|
247
|
+
await this.blobs?.invalidate({ bucket: this.bucketName, path: sidecar });
|
|
248
|
+
} catch {
|
|
249
|
+
// Nothing to clean up.
|
|
250
|
+
}
|
|
251
|
+
missingBlurhash.delete(`${this.bucketName}:${path}`);
|
|
252
|
+
}
|
|
140
253
|
}
|
|
141
254
|
|
|
142
255
|
async exists(path: string): Promise<boolean> {
|
|
@@ -1373,13 +1486,19 @@ export class Sp00kyClient<S extends SchemaStructure> {
|
|
|
1373
1486
|
}
|
|
1374
1487
|
|
|
1375
1488
|
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
1376
|
-
return new BucketHandle(name, this.remote, this.blobs
|
|
1489
|
+
return new BucketHandle(name, this.remote, this.blobs, {
|
|
1490
|
+
blurhash: this.config.blurhash,
|
|
1491
|
+
logger: this.logger,
|
|
1492
|
+
});
|
|
1377
1493
|
}
|
|
1378
1494
|
|
|
1379
1495
|
/** Cache-free handle. The blob cache reads the remote through this, so a
|
|
1380
1496
|
* cache miss can't loop back into the cache. */
|
|
1381
1497
|
private rawBucket(name: string): BucketHandle {
|
|
1382
|
-
return new BucketHandle(name, this.remote, null
|
|
1498
|
+
return new BucketHandle(name, this.remote, null, {
|
|
1499
|
+
blurhash: this.config.blurhash,
|
|
1500
|
+
logger: this.logger,
|
|
1501
|
+
});
|
|
1383
1502
|
}
|
|
1384
1503
|
|
|
1385
1504
|
/** Blob cache counters for DevTools. */
|
package/src/types.ts
CHANGED
|
@@ -296,6 +296,19 @@ export interface Sp00kyConfig<S extends SchemaStructure> {
|
|
|
296
296
|
* (or `degradeAfterConsecutiveFailures: 0`) to never report degraded.
|
|
297
297
|
*/
|
|
298
298
|
syncHealth?: SyncHealthConfig | false;
|
|
299
|
+
/**
|
|
300
|
+
* Automatic blurhash placeholders for bucket image uploads. On every
|
|
301
|
+
* `bucket.put` of an image path (by extension: webp/png/jpg/jpeg/gif/avif/bmp)
|
|
302
|
+
* the client computes a blurhash and stores it as a tiny sidecar object
|
|
303
|
+
* `<path>.bh` in the same bucket, best-effort. Read it back with
|
|
304
|
+
* `bucket.blurhash(path)` (or the client-solid `useBucketImage`/`BucketImage`
|
|
305
|
+
* helpers) to paint a placeholder until the image is decoded.
|
|
306
|
+
*
|
|
307
|
+
* `true` (the default) enables with 4x3 components; pass
|
|
308
|
+
* `{ componentX, componentY }` to tune detail, or `false` to disable.
|
|
309
|
+
* A per-call `put(path, content, { blurhash })` option overrides this.
|
|
310
|
+
*/
|
|
311
|
+
blurhash?: boolean | { componentX?: number; componentY?: number };
|
|
299
312
|
/**
|
|
300
313
|
* Deadline (ms) for a single outgoing mutation push. Tighter than
|
|
301
314
|
* {@link Sp00kyConfig.database.queryTimeoutMs} because the up-queue drains
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { encode, decode, isBlurhashValid } from 'blurhash';
|
|
2
|
+
|
|
3
|
+
export { encode as encodeBlurhash, decode as decodeBlurhash, isBlurhashValid };
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Blurhash generation settings. `true` enables with the defaults below, `false`
|
|
7
|
+
* disables. Resolution order for a put: per-call option > client config >
|
|
8
|
+
* default ON. See {@link Sp00kyConfig.blurhash}.
|
|
9
|
+
*/
|
|
10
|
+
export type BlurhashSetting = boolean | BlurhashEncodeOptions;
|
|
11
|
+
|
|
12
|
+
export interface BlurhashEncodeOptions {
|
|
13
|
+
/** Horizontal detail components, 1-9. Defaults to 4. */
|
|
14
|
+
componentX?: number;
|
|
15
|
+
/** Vertical detail components, 1-9. Defaults to 3. */
|
|
16
|
+
componentY?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where an image's blurhash lives: a tiny sidecar object in the same bucket.
|
|
21
|
+
* Buckets have no per-object metadata channel (`put` is just `.put($content)`),
|
|
22
|
+
* so the hash for `covers/x_t.webp` is the text object `covers/x_t.webp.bh`.
|
|
23
|
+
*/
|
|
24
|
+
export function blurhashSidecarPath(path: string): string {
|
|
25
|
+
return `${path}.bh`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Extensions `bucket.put` treats as images worth hashing. */
|
|
29
|
+
export const BLURHASH_IMAGE_EXTENSIONS = [
|
|
30
|
+
'webp',
|
|
31
|
+
'png',
|
|
32
|
+
'jpg',
|
|
33
|
+
'jpeg',
|
|
34
|
+
'gif',
|
|
35
|
+
'avif',
|
|
36
|
+
'bmp',
|
|
37
|
+
] as const;
|
|
38
|
+
|
|
39
|
+
const IMAGE_EXT_RE = new RegExp(`\\.(${BLURHASH_IMAGE_EXTENSIONS.join('|')})$`, 'i');
|
|
40
|
+
|
|
41
|
+
export function isImagePath(path: string): boolean {
|
|
42
|
+
return IMAGE_EXT_RE.test(path);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Longest edge of the downscale the hash is computed from. Blurhash carries at
|
|
46
|
+
* most 9x9 DCT components, so anything past ~32px is wasted decode work. */
|
|
47
|
+
const ENCODE_MAX_EDGE = 32;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Decode `content` as an image and compute its blurhash. Browser-only: returns
|
|
51
|
+
* null (never throws) when image decoding is unavailable (node, workers without
|
|
52
|
+
* canvas), when the bytes are not a decodable image, or on any other failure —
|
|
53
|
+
* a missing hash must never break the upload that triggered it.
|
|
54
|
+
*/
|
|
55
|
+
export async function encodeImageToBlurhash(
|
|
56
|
+
content: string | Uint8Array | Blob,
|
|
57
|
+
options?: BlurhashEncodeOptions
|
|
58
|
+
): Promise<string | null> {
|
|
59
|
+
if (typeof createImageBitmap !== 'function') return null;
|
|
60
|
+
let bitmap: ImageBitmap | null = null;
|
|
61
|
+
try {
|
|
62
|
+
const blob =
|
|
63
|
+
content instanceof Blob
|
|
64
|
+
? content
|
|
65
|
+
: new Blob([content as unknown as BlobPart]);
|
|
66
|
+
bitmap = await createImageBitmap(blob);
|
|
67
|
+
const scale = Math.min(1, ENCODE_MAX_EDGE / Math.max(bitmap.width, bitmap.height));
|
|
68
|
+
const width = Math.max(1, Math.round(bitmap.width * scale));
|
|
69
|
+
const height = Math.max(1, Math.round(bitmap.height * scale));
|
|
70
|
+
const canvas =
|
|
71
|
+
typeof OffscreenCanvas !== 'undefined'
|
|
72
|
+
? new OffscreenCanvas(width, height)
|
|
73
|
+
: typeof document !== 'undefined'
|
|
74
|
+
? Object.assign(document.createElement('canvas'), { width, height })
|
|
75
|
+
: null;
|
|
76
|
+
if (!canvas) return null;
|
|
77
|
+
const ctx = canvas.getContext('2d') as
|
|
78
|
+
| OffscreenCanvasRenderingContext2D
|
|
79
|
+
| CanvasRenderingContext2D
|
|
80
|
+
| null;
|
|
81
|
+
if (!ctx) return null;
|
|
82
|
+
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
83
|
+
const { data } = ctx.getImageData(0, 0, width, height);
|
|
84
|
+
return encode(data, width, height, options?.componentX ?? 4, options?.componentY ?? 3);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
} finally {
|
|
88
|
+
bitmap?.close();
|
|
89
|
+
}
|
|
90
|
+
}
|