@wasmedia/wenblend 0.1.0
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/LICENSE +367 -0
- package/LICENSE-GPL-3.0 +674 -0
- package/README.md +98 -0
- package/dist/cli.d.ts +27 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +167 -0
- package/dist/cli.js.map +1 -0
- package/dist/filesystem.d.ts +25 -0
- package/dist/filesystem.d.ts.map +1 -0
- package/dist/filesystem.js +75 -0
- package/dist/filesystem.js.map +1 -0
- package/dist/handler.d.ts +62 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +91 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/loader.d.ts +74 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +181 -0
- package/dist/loader.js.map +1 -0
- package/dist/operations.d.ts +45 -0
- package/dist/operations.d.ts.map +1 -0
- package/dist/operations.js +94 -0
- package/dist/operations.js.map +1 -0
- package/dist/staging.d.ts +37 -0
- package/dist/staging.d.ts.map +1 -0
- package/dist/staging.js +157 -0
- package/dist/staging.js.map +1 -0
- package/dist/tiff-header.d.ts +16 -0
- package/dist/tiff-header.d.ts.map +1 -0
- package/dist/tiff-header.js +97 -0
- package/dist/tiff-header.js.map +1 -0
- package/dist/types.d.ts +128 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +47 -0
- package/dist/types.js.map +1 -0
- package/dist/wenblend.js +2 -0
- package/dist/wenblend.wasm +0 -0
- package/dist/wenfuse.js +2 -0
- package/dist/wenfuse.wasm +0 -0
- package/dist/wopfs.d.ts +37 -0
- package/dist/wopfs.d.ts.map +1 -0
- package/dist/wopfs.js +38 -0
- package/dist/wopfs.js.map +1 -0
- package/dist/worker.d.ts +16 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +29 -0
- package/dist/worker.js.map +1 -0
- package/package.json +67 -0
- package/src/cli.ts +201 -0
- package/src/filesystem.ts +85 -0
- package/src/handler.ts +112 -0
- package/src/index.ts +41 -0
- package/src/loader.ts +223 -0
- package/src/operations.ts +147 -0
- package/src/staging.ts +173 -0
- package/src/tiff-header.ts +94 -0
- package/src/types.ts +153 -0
- package/src/wopfs.ts +70 -0
- package/src/worker.ts +31 -0
- package/types/wasm.d.ts +12 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MEMFS staging for N-input invocations.
|
|
3
|
+
*
|
|
4
|
+
* Enblend is N inputs to one output, so every call stages a set of files
|
|
5
|
+
* into a per-invocation directory and sweeps the whole directory
|
|
6
|
+
* afterwards. Nothing persists between calls.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { WasmModule, InputImage } from "./types.js";
|
|
10
|
+
|
|
11
|
+
let counter = 0;
|
|
12
|
+
|
|
13
|
+
export function toBytes(data: Uint8Array | ArrayBuffer): Uint8Array {
|
|
14
|
+
return data instanceof ArrayBuffer ? new Uint8Array(data) : data;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A fresh, unique work directory path. Unique naming ensures no collisions across invocations. */
|
|
18
|
+
export function nextWorkDirName(): string {
|
|
19
|
+
return `/work-${Date.now().toString(36)}-${(counter++).toString(36)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Create a unique work directory. */
|
|
23
|
+
export function createWorkDir(module: WasmModule): string {
|
|
24
|
+
const dir = nextWorkDirName();
|
|
25
|
+
module.FS.mkdir(dir);
|
|
26
|
+
return dir;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Write each input into the work directory under its caller-chosen name.
|
|
31
|
+
*
|
|
32
|
+
* Returns the set of input names so `collectOutputs` can tell the tool's
|
|
33
|
+
* output from what we put there.
|
|
34
|
+
*/
|
|
35
|
+
export function stageInputs(
|
|
36
|
+
module: WasmModule,
|
|
37
|
+
workDir: string,
|
|
38
|
+
inputs: InputImage[],
|
|
39
|
+
): Set<string> {
|
|
40
|
+
const names = new Set<string>();
|
|
41
|
+
for (const { name, data } of inputs) {
|
|
42
|
+
module.FS.writeFile(`${workDir}/${name}`, toBytes(data));
|
|
43
|
+
names.add(name);
|
|
44
|
+
}
|
|
45
|
+
return names;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read back everything in the work directory that was not staged as an input. */
|
|
49
|
+
export function collectOutputs(
|
|
50
|
+
module: WasmModule,
|
|
51
|
+
workDir: string,
|
|
52
|
+
inputNames: Set<string>,
|
|
53
|
+
): Map<string, Uint8Array> {
|
|
54
|
+
const outputs = new Map<string, Uint8Array>();
|
|
55
|
+
for (const entry of module.FS.readdir(workDir)) {
|
|
56
|
+
if (entry === "." || entry === "..") continue;
|
|
57
|
+
if (inputNames.has(entry)) continue;
|
|
58
|
+
outputs.set(entry, module.FS.readFile(`${workDir}/${entry}`));
|
|
59
|
+
}
|
|
60
|
+
return outputs;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Remove every file in the work directory, then the directory itself. */
|
|
64
|
+
export function cleanWorkDir(module: WasmModule, workDir: string): void {
|
|
65
|
+
for (const entry of module.FS.readdir(workDir)) {
|
|
66
|
+
if (entry === "." || entry === "..") continue;
|
|
67
|
+
const path = `${workDir}/${entry}`;
|
|
68
|
+
try {
|
|
69
|
+
module.FS.unlink(path);
|
|
70
|
+
} catch {
|
|
71
|
+
// Entry might be a directory. Attempt rmdir as fallback; continue
|
|
72
|
+
// either way to ensure the whole directory is swept as defense-in-depth.
|
|
73
|
+
try {
|
|
74
|
+
module.FS.rmdir(path);
|
|
75
|
+
} catch {
|
|
76
|
+
// Best effort: one bad entry must not abort the sweep.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
module.FS.rmdir(workDir);
|
|
82
|
+
} catch {
|
|
83
|
+
// Best effort: work directory cleanup failure does not abort.
|
|
84
|
+
}
|
|
85
|
+
}
|
package/src/handler.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The worker's message handler and its scope test, with NO import-time
|
|
3
|
+
* side effects.
|
|
4
|
+
*
|
|
5
|
+
* This module is what the package's main entry (`src/index.ts`) re-exports.
|
|
6
|
+
* It must never register anything when imported: a consumer who writes
|
|
7
|
+
* their own Web Worker and does `import { blend } from "@wasmedia/wenblend"`
|
|
8
|
+
* inside it would otherwise get our `message` listener attached to THEIR
|
|
9
|
+
* worker, and any of their own messages carrying an `images` array would
|
|
10
|
+
* run a blend or fuse they never asked for. Registration lives only in
|
|
11
|
+
* `src/worker.ts`, the package's dedicated `./worker` entry.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { BlendOptions, FuseOptions, FileInput, OutputDir, WrittenFile } from "./types.js";
|
|
15
|
+
import { runOperation, type ImageInput } from "./operations.js";
|
|
16
|
+
import { isFileInput } from "./staging.js";
|
|
17
|
+
|
|
18
|
+
export interface WorkerRequest {
|
|
19
|
+
id: string;
|
|
20
|
+
op: "blend" | "fuse";
|
|
21
|
+
images: ImageInput[] | FileInput[];
|
|
22
|
+
options?: (BlendOptions | FuseOptions) & { outputDir?: OutputDir };
|
|
23
|
+
/** Where file mode loads @wasmedia/wopfs from; see LoaderOptions.wopfsPath. */
|
|
24
|
+
wopfsPath?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface WorkerResponse {
|
|
28
|
+
id: string;
|
|
29
|
+
ok: boolean;
|
|
30
|
+
result?: Uint8Array | WrittenFile;
|
|
31
|
+
error?: { name: string; message: string; stderr?: string; exitCode?: number };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OPS = { blend: "enblend", fuse: "enfuse" } as const;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Handle one request. Never throws and never rejects: worker.ts posts
|
|
38
|
+
* whatever this resolves to, so a throw would leave the caller waiting for a
|
|
39
|
+
* reply that never comes. Messages arrive untyped at runtime, so nothing
|
|
40
|
+
* about `request` is trusted — not its shape, not its `op`.
|
|
41
|
+
*/
|
|
42
|
+
export async function handleMessage(request: WorkerRequest): Promise<WorkerResponse> {
|
|
43
|
+
// Echo the caller's id unchanged, whatever its runtime type: it exists only
|
|
44
|
+
// so the caller can correlate the reply. Plain-JS callers may use numbers.
|
|
45
|
+
const id = typeof request === "object" && request !== null ? request.id : "";
|
|
46
|
+
try {
|
|
47
|
+
if (typeof request !== "object" || request === null) {
|
|
48
|
+
return { id, ok: false, error: { name: "Error", message: "invalid request" } };
|
|
49
|
+
}
|
|
50
|
+
// Route only the two ops we know; anything else is rejected rather than
|
|
51
|
+
// quietly run as one of the tools. Only a string can name one, so a
|
|
52
|
+
// non-string op (which could carry a throwing toString) never reaches
|
|
53
|
+
// the lookup.
|
|
54
|
+
const opName = request.op;
|
|
55
|
+
const op = typeof opName === "string" && Object.hasOwn(OPS, opName) ? OPS[opName] : undefined;
|
|
56
|
+
if (!op) {
|
|
57
|
+
const shown = typeof opName === "string" ? `"${opName}"` : typeof opName;
|
|
58
|
+
return { id, ok: false, error: { name: "Error", message: `unknown op ${shown}` } };
|
|
59
|
+
}
|
|
60
|
+
if (request.wopfsPath !== undefined && typeof request.wopfsPath !== "string") {
|
|
61
|
+
return { id, ok: false, error: { name: "Error", message: "wopfsPath must be a string" } };
|
|
62
|
+
}
|
|
63
|
+
// Check if file mode is requested: all images are FileInputs
|
|
64
|
+
const imagesArray = Array.isArray(request.images) ? request.images : [];
|
|
65
|
+
const isFileMode = imagesArray.length > 0 && imagesArray.every(isFileInput);
|
|
66
|
+
if (isFileMode && !request.options?.outputDir) {
|
|
67
|
+
return { id, ok: false, error: { name: "Error", message: "file mode requires options.outputDir" } };
|
|
68
|
+
}
|
|
69
|
+
const result = await runOperation(op, request.images, request.options, { wopfsPath: request.wopfsPath });
|
|
70
|
+
return { id, ok: true, result };
|
|
71
|
+
} catch (e: unknown) {
|
|
72
|
+
const { stderr, exitCode } = (e ?? {}) as { stderr?: unknown; exitCode?: unknown };
|
|
73
|
+
return {
|
|
74
|
+
id,
|
|
75
|
+
ok: false,
|
|
76
|
+
error: {
|
|
77
|
+
name: e instanceof Error ? e.name : "Error",
|
|
78
|
+
message: e instanceof Error ? e.message : String(e),
|
|
79
|
+
stderr: typeof stderr === "string" ? stderr : undefined,
|
|
80
|
+
// Error instances do not survive postMessage as themselves, so the
|
|
81
|
+
// exit code has to travel as plain data or the caller loses it.
|
|
82
|
+
exitCode: typeof exitCode === "number" ? exitCode : undefined,
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* True only inside an actual dedicated/shared/service worker.
|
|
90
|
+
*
|
|
91
|
+
* `typeof self !== "undefined"` is NOT a worker test: on a browser main
|
|
92
|
+
* thread `self === window`, and `window.postMessage` is a perfectly
|
|
93
|
+
* ordinary function, so a guard built on those two checks alone is true
|
|
94
|
+
* on every web page, not just inside a worker. Importing the dedicated
|
|
95
|
+
* `./worker` entry (`src/worker.ts`) would then attach a `message`
|
|
96
|
+
* listener to `window` on any ordinary page and treat arbitrary
|
|
97
|
+
* postMessage traffic (other scripts, extensions, iframes) as a
|
|
98
|
+
* WorkerRequest.
|
|
99
|
+
*
|
|
100
|
+
* `WorkerGlobalScope` only exists as a global inside a worker context,
|
|
101
|
+
* and `self` is an instance of it there. Both `typeof` checks are
|
|
102
|
+
* ordered so the `instanceof` is only reached once both globals are
|
|
103
|
+
* confirmed to exist, so this can never throw — including in plain
|
|
104
|
+
* Node, which has neither global.
|
|
105
|
+
*/
|
|
106
|
+
export function isWorkerScope(): boolean {
|
|
107
|
+
return (
|
|
108
|
+
typeof WorkerGlobalScope !== "undefined" &&
|
|
109
|
+
typeof self !== "undefined" &&
|
|
110
|
+
self instanceof WorkerGlobalScope
|
|
111
|
+
);
|
|
112
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wenblend — Enblend and Enfuse compiled to WebAssembly.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```typescript
|
|
6
|
+
* import { blend } from "@wasmedia/wenblend";
|
|
7
|
+
*
|
|
8
|
+
* // Inputs must be RGBA TIFFs carrying correct XPosition/YPosition tags.
|
|
9
|
+
* const panorama = await blend([left, right]);
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { blend, fuse, normalizeInputs, runOperation, type ImageInput } from "./operations.js";
|
|
14
|
+
export { enblend, enfuse, runTool } from "./cli.js";
|
|
15
|
+
export {
|
|
16
|
+
createInstance,
|
|
17
|
+
loadFactory,
|
|
18
|
+
type Tool,
|
|
19
|
+
type ModuleFactory,
|
|
20
|
+
type InstanceHooks,
|
|
21
|
+
} from "./loader.js";
|
|
22
|
+
export { handleMessage, isWorkerScope, type WorkerRequest, type WorkerResponse } from "./handler.js";
|
|
23
|
+
export {
|
|
24
|
+
EnblendError,
|
|
25
|
+
OutOfMemoryError,
|
|
26
|
+
type WasmModule,
|
|
27
|
+
type EmscriptenFS,
|
|
28
|
+
type FSStat,
|
|
29
|
+
type CLIResult,
|
|
30
|
+
type FileInput,
|
|
31
|
+
type OutputDir,
|
|
32
|
+
type WrittenFile,
|
|
33
|
+
type CLIFileResult,
|
|
34
|
+
type FileModeOptions,
|
|
35
|
+
type InputImage,
|
|
36
|
+
type BlendOptions,
|
|
37
|
+
type FuseOptions,
|
|
38
|
+
type CommonOptions,
|
|
39
|
+
type LoaderOptions,
|
|
40
|
+
type LoadPhase,
|
|
41
|
+
} from "./types.js";
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-tool wasm module loading.
|
|
3
|
+
*
|
|
4
|
+
* Two facts drive this file:
|
|
5
|
+
*
|
|
6
|
+
* 1. Each tool is its own ~3 MB module. A page that only blends must
|
|
7
|
+
* not pay for the fuse module, so the import is per-tool and lazy.
|
|
8
|
+
* 2. Both tools keep state in C++ globals and neither resets it, so a
|
|
9
|
+
* module INSTANCE is never reused across invocations.
|
|
10
|
+
*
|
|
11
|
+
* So two things are cached per tool and one is not:
|
|
12
|
+
*
|
|
13
|
+
* - the JS glue's factory (the dynamic import), and
|
|
14
|
+
* - the COMPILED `WebAssembly.Module`, fetched and compiled once and
|
|
15
|
+
* handed to every later factory call through Emscripten's
|
|
16
|
+
* `instantiateWasm` hook, so a new instance costs an instantiate,
|
|
17
|
+
* not a re-download and re-compile of ~3 MB;
|
|
18
|
+
* - never the instance itself.
|
|
19
|
+
*
|
|
20
|
+
* Without the hook, each factory call runs the glue's own createWasm,
|
|
21
|
+
* which fetches and compiles the .wasm from scratch every time.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { WasmModule, LoaderOptions } from "./types.js";
|
|
25
|
+
|
|
26
|
+
export type Tool = "enblend" | "enfuse";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Output hooks, bound at construction.
|
|
30
|
+
*
|
|
31
|
+
* Emscripten reads `Module["print"]`/`Module["printErr"]` exactly once,
|
|
32
|
+
* inside the factory body, and binds them into module-local variables
|
|
33
|
+
* before the factory's promise resolves. Assigning `module.print` after
|
|
34
|
+
* `await factory(...)` returns is inert. Callers that want to capture
|
|
35
|
+
* output must build their closures first and pass them into
|
|
36
|
+
* `createInstance`.
|
|
37
|
+
*/
|
|
38
|
+
export type InstanceHooks = Pick<WasmModule, "print" | "printErr">;
|
|
39
|
+
|
|
40
|
+
/** A tool's Emscripten module factory: the default export of its `.js` glue. */
|
|
41
|
+
export type ModuleFactory = (config?: Record<string, unknown>) => Promise<WasmModule>;
|
|
42
|
+
type FactoryLoader = (tool: Tool, options?: LoaderOptions) => Promise<ModuleFactory>;
|
|
43
|
+
|
|
44
|
+
const MODULE_FILE: Record<Tool, string> = {
|
|
45
|
+
enblend: "wenblend.js",
|
|
46
|
+
enfuse: "wenfuse.js",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const WASM_FILE: Record<Tool, string> = {
|
|
50
|
+
enblend: "wenblend.wasm",
|
|
51
|
+
enfuse: "wenfuse.wasm",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
// Both caches are keyed on the tool AND the absolute glue URL, so a second
|
|
55
|
+
// call with a different `wasmPath` loads that module instead of silently
|
|
56
|
+
// reusing the first. Relative and absolute spellings of one location resolve
|
|
57
|
+
// to the same key.
|
|
58
|
+
const factories = new Map<string, Promise<ModuleFactory>>();
|
|
59
|
+
const compiled = new Map<string, Promise<WebAssembly.Module>>();
|
|
60
|
+
|
|
61
|
+
function glueUrl(tool: Tool, options?: LoaderOptions): string {
|
|
62
|
+
return options?.wasmPath
|
|
63
|
+
?? new URL(`../dist/${MODULE_FILE[tool]}`, import.meta.url).href;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The glue's absolute URL. `new URL` needs an absolute base, but `wasmPath`
|
|
68
|
+
* may be relative (what bundlers hand out, e.g. Vite's `?url`), so it is
|
|
69
|
+
* resolved against this module — the same base `import()` uses for the glue.
|
|
70
|
+
*/
|
|
71
|
+
function resolvedGlueUrl(tool: Tool, options?: LoaderOptions): URL {
|
|
72
|
+
return new URL(glueUrl(tool, options), import.meta.url);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function cacheKey(tool: Tool, options?: LoaderOptions): string {
|
|
76
|
+
return `${tool} ${resolvedGlueUrl(tool, options).href}`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function importFactory(tool: Tool, options?: LoaderOptions): Promise<ModuleFactory> {
|
|
80
|
+
const mod = await import(/* webpackIgnore: true */ glueUrl(tool, options));
|
|
81
|
+
return (mod.default ?? mod) as ModuleFactory;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Fetch and compile a tool's .wasm.
|
|
86
|
+
*
|
|
87
|
+
* The URL is the one the glue itself would use: its `locateFile` default
|
|
88
|
+
* resolves the .wasm name against the glue's own directory. `file:` URLs
|
|
89
|
+
* (Node) are read from disk, because Node's fetch does not do `file:`;
|
|
90
|
+
* everything else is fetched, streaming-compiled where the server's MIME
|
|
91
|
+
* type allows and compiled from bytes where it does not — the same
|
|
92
|
+
* fallback the glue performs.
|
|
93
|
+
*/
|
|
94
|
+
async function compileWasm(tool: Tool, options?: LoaderOptions): Promise<WebAssembly.Module> {
|
|
95
|
+
const url = new URL(WASM_FILE[tool], resolvedGlueUrl(tool, options));
|
|
96
|
+
if (url.protocol === "file:") {
|
|
97
|
+
// A variable specifier keeps bundlers from trying to resolve a Node
|
|
98
|
+
// builtin into browser code; this branch only runs under Node.
|
|
99
|
+
const fsSpec = "node:fs/promises";
|
|
100
|
+
const urlSpec = "node:url";
|
|
101
|
+
const { readFile } = await import(/* webpackIgnore: true */ /* @vite-ignore */ fsSpec);
|
|
102
|
+
const { fileURLToPath } = await import(/* webpackIgnore: true */ /* @vite-ignore */ urlSpec);
|
|
103
|
+
return WebAssembly.compile(await readFile(fileURLToPath(url)));
|
|
104
|
+
}
|
|
105
|
+
const response = fetch(url.href, { credentials: "same-origin" });
|
|
106
|
+
if (typeof WebAssembly.compileStreaming === "function") {
|
|
107
|
+
try {
|
|
108
|
+
return await WebAssembly.compileStreaming(response);
|
|
109
|
+
} catch {
|
|
110
|
+
// Wrong MIME type, most often. Fall through to compiling bytes.
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const res = await response;
|
|
114
|
+
if (!res.ok) throw new Error(`failed to fetch ${url.href}: HTTP ${res.status}`);
|
|
115
|
+
return WebAssembly.compile(await res.arrayBuffer());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let compileWasmImpl: (tool: Tool, options?: LoaderOptions) => Promise<WebAssembly.Module> = compileWasm;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The compiled module for a tool, compiled at most once per resolved URL.
|
|
122
|
+
*
|
|
123
|
+
* A promise is cached, so concurrent first calls share one compile. A
|
|
124
|
+
* failed compile is evicted, so a transient network error is retried on
|
|
125
|
+
* the next call rather than poisoning the cache for the page's lifetime.
|
|
126
|
+
*/
|
|
127
|
+
async function loadCompiled(tool: Tool, options?: LoaderOptions): Promise<WebAssembly.Module> {
|
|
128
|
+
const key = cacheKey(tool, options);
|
|
129
|
+
let existing = compiled.get(key);
|
|
130
|
+
if (!existing) {
|
|
131
|
+
existing = compileWasmImpl(tool, options).catch((err) => {
|
|
132
|
+
compiled.delete(key);
|
|
133
|
+
throw err;
|
|
134
|
+
});
|
|
135
|
+
compiled.set(key, existing);
|
|
136
|
+
}
|
|
137
|
+
return existing;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let loadFactoryImpl: FactoryLoader = importFactory;
|
|
141
|
+
|
|
142
|
+
/** Test seam: replace the dynamic import. */
|
|
143
|
+
export function __setFactoryLoader(fn: FactoryLoader): void {
|
|
144
|
+
loadFactoryImpl = fn;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Test seam: replace the wasm fetch-and-compile step. */
|
|
148
|
+
export function __setWasmCompiler(
|
|
149
|
+
fn: (tool: Tool, options?: LoaderOptions) => Promise<WebAssembly.Module>,
|
|
150
|
+
): void {
|
|
151
|
+
compileWasmImpl = fn;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Test seam: drop cached factories and compiled modules. */
|
|
155
|
+
export function resetFactoryCache(): void {
|
|
156
|
+
factories.clear();
|
|
157
|
+
compiled.clear();
|
|
158
|
+
loadFactoryImpl = importFactory;
|
|
159
|
+
compileWasmImpl = compileWasm;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Import a tool's JS glue and return its module factory. Cached per tool and resolved glue URL.
|
|
164
|
+
*
|
|
165
|
+
* Calling the returned factory directly compiles the .wasm afresh on every
|
|
166
|
+
* call; `createInstance` is what supplies the cached compiled module.
|
|
167
|
+
*
|
|
168
|
+
* `async` so that a `wasmPath` that is not a valid URL rejects rather than
|
|
169
|
+
* throwing synchronously. The cache lookup still happens before the first
|
|
170
|
+
* `await`, so concurrent first calls share one import.
|
|
171
|
+
*/
|
|
172
|
+
export async function loadFactory(tool: Tool, options?: LoaderOptions): Promise<ModuleFactory> {
|
|
173
|
+
const key = cacheKey(tool, options);
|
|
174
|
+
let existing = factories.get(key);
|
|
175
|
+
if (!existing) {
|
|
176
|
+
existing = loadFactoryImpl(tool, options).catch((err) => {
|
|
177
|
+
factories.delete(key);
|
|
178
|
+
throw err;
|
|
179
|
+
});
|
|
180
|
+
factories.set(key, existing);
|
|
181
|
+
}
|
|
182
|
+
return existing;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Build a fresh module instance.
|
|
187
|
+
*
|
|
188
|
+
* Never cache the result. See the file header.
|
|
189
|
+
*
|
|
190
|
+
* `hooks` is passed straight into the factory's config object, since that
|
|
191
|
+
* is the only point at which `print`/`printErr` can take effect (see
|
|
192
|
+
* `InstanceHooks`).
|
|
193
|
+
*
|
|
194
|
+
* The compiled module reaches the glue through `instantiateWasm`: the glue
|
|
195
|
+
* calls it with its import object in place of its own fetch-and-compile,
|
|
196
|
+
* and waits for `receiveInstance`. That wait has no rejection path — the
|
|
197
|
+
* glue wraps the hook in a promise whose `reject` it never calls — so a
|
|
198
|
+
* compile or instantiate failure is raced against the factory here, or
|
|
199
|
+
* the call would hang forever instead of rejecting.
|
|
200
|
+
*/
|
|
201
|
+
export async function createInstance(
|
|
202
|
+
tool: Tool,
|
|
203
|
+
options?: LoaderOptions,
|
|
204
|
+
hooks?: Partial<InstanceHooks>,
|
|
205
|
+
): Promise<WasmModule> {
|
|
206
|
+
options?.onProgress?.("loading");
|
|
207
|
+
const factory = await loadFactory(tool, options);
|
|
208
|
+
options?.onProgress?.("initializing");
|
|
209
|
+
let fail!: (err: unknown) => void;
|
|
210
|
+
const failed = new Promise<never>((_, reject) => { fail = reject; });
|
|
211
|
+
const instantiateWasm = (
|
|
212
|
+
imports: WebAssembly.Imports,
|
|
213
|
+
receiveInstance: (instance: WebAssembly.Instance, module: WebAssembly.Module) => void,
|
|
214
|
+
): Record<string, never> => {
|
|
215
|
+
loadCompiled(tool, options)
|
|
216
|
+
.then(async (module) => receiveInstance(await WebAssembly.instantiate(module, imports), module))
|
|
217
|
+
.catch(fail);
|
|
218
|
+
return {};
|
|
219
|
+
};
|
|
220
|
+
const instance = await Promise.race([factory({ ...hooks, instantiateWasm }), failed]);
|
|
221
|
+
options?.onProgress?.("ready");
|
|
222
|
+
return instance;
|
|
223
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level functional API.
|
|
3
|
+
*
|
|
4
|
+
* Enblend is invocation-oriented — N inputs plus flags produce one
|
|
5
|
+
* output — so this layer is a thin argv builder rather than the
|
|
6
|
+
* chainable object the single-image siblings expose.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { InputImage, BlendOptions, FuseOptions, LoaderOptions, FileInput, OutputDir, WrittenFile } from "./types.js";
|
|
10
|
+
import { EnblendError } from "./types.js";
|
|
11
|
+
import { enblend, enfuse } from "./cli.js";
|
|
12
|
+
import type { Tool } from "./loader.js";
|
|
13
|
+
import { isFileInput } from "./staging.js";
|
|
14
|
+
|
|
15
|
+
export type ImageInput = InputImage | Uint8Array | ArrayBuffer;
|
|
16
|
+
|
|
17
|
+
const DEFAULT_OUTPUT = "output.tif";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Give every input a name, auto-naming bare buffers positionally.
|
|
21
|
+
*
|
|
22
|
+
* Throws on a name collision rather than silently renaming: a caller who
|
|
23
|
+
* supplies an explicit name may reference it again in `options.args`
|
|
24
|
+
* (an `--exclude somefile.tif` flag, say), so renaming around a clash
|
|
25
|
+
* could silently break a working invocation in a new way. Two inputs
|
|
26
|
+
* sharing a name would otherwise stage to the same MEMFS path — the
|
|
27
|
+
* second overwriting the first — and the tool would run to completion
|
|
28
|
+
* on the wrong input set with no error, which is exactly the kind of
|
|
29
|
+
* silent corruption this port cannot afford another instance of.
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeInputs(images: ImageInput[]): InputImage[];
|
|
32
|
+
export function normalizeInputs(images: FileInput[]): FileInput[];
|
|
33
|
+
export function normalizeInputs(images: ImageInput[] | FileInput[]): InputImage[] | FileInput[] {
|
|
34
|
+
const seen = new Set<string>();
|
|
35
|
+
return (images as Array<ImageInput | FileInput>).map((image, i) => {
|
|
36
|
+
const input = image instanceof Uint8Array || image instanceof ArrayBuffer
|
|
37
|
+
? { name: `in${i}.tif`, data: image }
|
|
38
|
+
: image;
|
|
39
|
+
if (seen.has(input.name)) {
|
|
40
|
+
throw new Error(`normalizeInputs: duplicate input name "${input.name}"`);
|
|
41
|
+
}
|
|
42
|
+
seen.add(input.name);
|
|
43
|
+
return input;
|
|
44
|
+
}) as InputImage[] | FileInput[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Short options that take an argument in enblend or enfuse ("v" and "w" take
|
|
48
|
+
// an optional one). getopt treats the rest of a cluster after any of these as
|
|
49
|
+
// that argument, so scanning a cluster for "o" stops at the first of them.
|
|
50
|
+
const SHORT_WITH_ARGUMENT = new Set(["b", "d", "f", "l", "m", "o", "v", "w"]);
|
|
51
|
+
|
|
52
|
+
/** Whether getopt_long would read `arg` as the output option, as enblend and enfuse define it. */
|
|
53
|
+
function isOutputOption(arg: string): boolean {
|
|
54
|
+
if (arg.startsWith("--")) {
|
|
55
|
+
// Any unambiguous prefix of a long option is accepted, so "--out" is
|
|
56
|
+
// "--output". "--o" is ambiguous in enblend (it has --optimize too) but
|
|
57
|
+
// means --output in enfuse; rejecting it costs nothing either way.
|
|
58
|
+
const name = arg.slice(2).split("=", 1)[0];
|
|
59
|
+
return name.startsWith("o") && "output".startsWith(name);
|
|
60
|
+
}
|
|
61
|
+
if (!arg.startsWith("-")) return false;
|
|
62
|
+
for (const c of arg.slice(1)) {
|
|
63
|
+
if (c === "o") return true;
|
|
64
|
+
if (SHORT_WITH_ARGUMENT.has(c)) return false;
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Reject output settings that could only end in a confusing "produced no
|
|
71
|
+
* output file" after a full run. The tool writes into its per-invocation
|
|
72
|
+
* work directory, and the output is collected from there by name, so it
|
|
73
|
+
* must be a plain file name that no input shares, and set only once.
|
|
74
|
+
*/
|
|
75
|
+
function validateOutput(output: string, args: readonly string[], inputs: Array<{ name: string }>): void {
|
|
76
|
+
if (output === "" || output === "." || output === ".." || /[/\\]/.test(output)) {
|
|
77
|
+
throw new Error(`options.output must be a plain file name, got "${output}"`);
|
|
78
|
+
}
|
|
79
|
+
const setsOutput = args.find(isOutputOption);
|
|
80
|
+
if (setsOutput !== undefined) {
|
|
81
|
+
throw new Error(`set the output file with options.output, not "${setsOutput}" in args`);
|
|
82
|
+
}
|
|
83
|
+
if (inputs.some((i) => i.name === output)) {
|
|
84
|
+
throw new Error(`input name "${output}" collides with the output file name`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function runOperation(
|
|
89
|
+
tool: Tool,
|
|
90
|
+
images: ImageInput[] | FileInput[],
|
|
91
|
+
options: (BlendOptions | FuseOptions) & { outputDir?: OutputDir } = {},
|
|
92
|
+
loaderOptions?: LoaderOptions,
|
|
93
|
+
): Promise<Uint8Array | WrittenFile> {
|
|
94
|
+
if (images.length === 0) {
|
|
95
|
+
throw new Error(`${tool} requires at least one input image`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const files = (images as unknown[]).every(isFileInput);
|
|
99
|
+
const inputs = normalizeInputs(images as never) as Array<InputImage | FileInput>;
|
|
100
|
+
const output = options.output ?? DEFAULT_OUTPUT;
|
|
101
|
+
validateOutput(output, options.args ?? [], inputs);
|
|
102
|
+
const argv = ["-o", output, ...(options.args ?? []), ...inputs.map((i) => i.name)];
|
|
103
|
+
|
|
104
|
+
const invoke = tool === "enblend" ? enblend : enfuse;
|
|
105
|
+
const result = files
|
|
106
|
+
? await invoke(argv, inputs as FileInput[], { ...loaderOptions, outputDir: options.outputDir! })
|
|
107
|
+
: await invoke(argv, inputs as InputImage[], loaderOptions);
|
|
108
|
+
|
|
109
|
+
if (result.exitCode !== 0) {
|
|
110
|
+
throw new EnblendError(
|
|
111
|
+
`${tool} exited with code ${result.exitCode}${result.stderr ? `: ${result.stderr}` : ""}`,
|
|
112
|
+
result.exitCode,
|
|
113
|
+
result.stderr,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const produced = "written" in result ? result.written.get(output) : result.outputFiles.get(output);
|
|
118
|
+
if (!produced) {
|
|
119
|
+
throw new EnblendError(
|
|
120
|
+
`${tool} exited cleanly but produced no output file "${output}"`,
|
|
121
|
+
0,
|
|
122
|
+
result.stderr,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return produced;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Blend overlapping images into one.
|
|
130
|
+
*
|
|
131
|
+
* Enblend positions images by their TIFF XPosition/YPosition tags, not
|
|
132
|
+
* by any argument. Inputs without correct offsets — two unaligned
|
|
133
|
+
* JPEGs, say — produce a valid file containing a useless image. Use
|
|
134
|
+
* remapped output from a stitcher.
|
|
135
|
+
*/
|
|
136
|
+
export function blend(images: ImageInput[], options?: BlendOptions, loaderOptions?: LoaderOptions): Promise<Uint8Array>;
|
|
137
|
+
export function blend(images: FileInput[], options: BlendOptions & { outputDir: OutputDir }, loaderOptions?: LoaderOptions): Promise<WrittenFile>;
|
|
138
|
+
export function blend(images: ImageInput[] | FileInput[], options?: BlendOptions & { outputDir?: OutputDir }, loaderOptions?: LoaderOptions) {
|
|
139
|
+
return runOperation("enblend", images, options, loaderOptions);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Fuse a bracketed exposure series into one image. */
|
|
143
|
+
export function fuse(images: ImageInput[], options?: FuseOptions, loaderOptions?: LoaderOptions): Promise<Uint8Array>;
|
|
144
|
+
export function fuse(images: FileInput[], options: FuseOptions & { outputDir: OutputDir }, loaderOptions?: LoaderOptions): Promise<WrittenFile>;
|
|
145
|
+
export function fuse(images: ImageInput[] | FileInput[], options?: FuseOptions & { outputDir?: OutputDir }, loaderOptions?: LoaderOptions) {
|
|
146
|
+
return runOperation("enfuse", images, options, loaderOptions);
|
|
147
|
+
}
|